From c7d744e615491308ba1737b88a68ae9f4d54e11e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 00:08:00 +0000 Subject: [PATCH 01/33] docs: add Sentry integration plan Plans an opt-in, host-app-driven Sentry integration covering: - error capture across backend (Node), JS/RN, and native layers - RPC tracing via @comapeo/ipc onRequestHook (mirrors comapeo-mobile) - forwarding @comapeo/core OpenTelemetry spans (PR digidem/comapeo-core#1051) - app-specific gating so non-CoMapeo consumers ship no Sentry traffic https://claude.ai/code/session_01EcVXzczA1TVkhEkgUg9DKX --- docs/sentry-integration-plan.md | 913 ++++++++++++++++++++++++++++++++ 1 file changed, 913 insertions(+) create mode 100644 docs/sentry-integration-plan.md diff --git a/docs/sentry-integration-plan.md b/docs/sentry-integration-plan.md new file mode 100644 index 0000000..9df4b1e --- /dev/null +++ b/docs/sentry-integration-plan.md @@ -0,0 +1,913 @@ +# Sentry Integration Plan + +How we propose to wire Sentry error reporting and RPC tracing into +`@comapeo/core-react-native` without forcing every consumer of this +module to ship Sentry. The integration is **opt-in and host-app +driven** so that only the CoMapeo Mobile app pays the bundle cost, +sends events to a DSN, and sees its data in Sentry — other apps that +depend on this module continue to ship with no Sentry traffic and no +Sentry binaries. + +Companion docs: +- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — process model, IPC, lifecycle. +- Reference implementation in CoMapeo Mobile: + [`comapeo-mobile/src/backend/src/app.js`](https://github.com/digidem/comapeo-mobile/blob/develop/src/backend/src/app.js). +- Upstream OpenTelemetry instrumentation in `@comapeo/core`: + [`comapeo-core PR #1051`](https://github.com/digidem/comapeo-core/pull/1051). + +--- + +## 1. Goals & non-goals + +### Goals + +1. **Capture errors** raised at every layer the module owns: + - Node backend: `uncaughtException`, `unhandledRejection`, boot + phase failures (`listen-control`, `init`, `construct`, + `runtime`), per-RPC throws. + - RN/JS layer: `state` ERROR transitions, `messageerror` protocol + parse failures, RPC client rejections. + - Native: rootkey load failures, watchdog timeouts, IPC + connection errors, hard process crashes (Android FGS, + iOS in-process). +2. **Trace RPC calls** end-to-end across the React Native ↔ Node + boundary, mirroring the `onRequestHook` pattern used in + `comapeo-mobile/src/backend/src/app.js`. Each RPC call appears + as a transaction whose parent span is the JS-side caller. +3. **Forward OpenTelemetry spans** emitted by `@comapeo/core` (once + PR #1051 lands) to Sentry without bundle-time coupling to a + specific exporter. +4. **App-specific gating**: zero Sentry traffic, zero Sentry SDK + activation, and ideally zero meaningful bundle delta for any + consumer that doesn't opt in. + +### Non-goals + +- We are not adding a generic telemetry abstraction. The module + speaks Sentry-shaped APIs (DSN, `Sentry.captureException`, + OpenTelemetry-compatible spans). Other backends are out of scope. +- We are not capturing user-PII or message contents. Spans get + method names and structural metadata, not arguments. +- We are not auto-installing Sentry SDKs on the host app's behalf. + The host app declares the dependency; the module just wires it in. + +--- + +## 2. Why "app-specific" matters here + +`@comapeo/core-react-native` is a library. It has at least two +different consumers expected over time (the CoMapeo Mobile app, and +the in-tree `apps/example` integration harness — and potentially +third-party apps building on the module). We cannot: + +- **Bundle a hard dependency on `@sentry/node` into the published + Node backend.** That bundle is staged into + `android/src/{debug,main}/assets/nodejs-project/` and + `ios/nodejs-project/` at `npm run backend:build` time + (see `backend/rollup.config.ts` and + `scripts/build-backend.ts`). Whatever ends up in the rollup is on + every consumer's device, regardless of whether they want Sentry. +- **Hard-import `@sentry/react-native` from `src/`.** Doing so + would force every consumer to install it, and any consumer that + does not call `Sentry.init()` would still get a runtime warning + from the module attempting to use an uninitialized client. +- **Ship a DSN.** The DSN is per-app secret (well, per-app config). + It belongs in the host app's environment, not in the published + module's source. + +The integration must therefore be: + +1. **Inert by default.** Module installed but not configured → no + Sentry calls, no SDK init, no trace metadata on RPC frames. +2. **Activated by the host app.** A single configuration entry + point, called from the host app's startup code, switches + instrumentation on with a DSN, environment, release, sample + rates, etc. +3. **Reachable from all three layers.** The same call from JS must + propagate to the Node backend (so it can `Sentry.init()` and + register `onRequestHook`) and to native (so iOS/Android crash + reporters can be enabled). + +--- + +## 3. Layered architecture + +There are three independent Sentry scopes to manage. They share a +DSN and a release tag, but each runs in its own process / runtime +and needs its own SDK init. + +``` +┌──────────────────────────── Host app ─────────────────────────────┐ +│ │ +│ ┌─────────────── React Native (JS) ────────────────┐ │ +│ │ @sentry/react-native │ │ +│ │ - JS errors, native crashes (iOS+Android) │ │ +│ │ - starts trace for RPC calls │ │ +│ │ │ │ +│ │ @comapeo/core-react-native: │ │ +│ │ - state.on('stateChange', ERROR) → captureException │ +│ │ - state.on('messageerror', ...) → captureException │ +│ │ - comapeo.() wrapper: startSpan + │ │ +│ │ attach sentry-trace + baggage in metadata │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ │ +│ │ control.sock {type:"init",sentry:…} │ +│ │ comapeo.sock RPC (with sentry-trace) │ +│ ▼ │ +│ ┌─────────────────── Node backend ─────────────────┐ │ +│ │ @sentry/node (bundled, init only on opt-in) │ │ +│ │ - handleFatal → captureException │ │ +│ │ - createMapeoServer({ onRequestHook }) → spans │ │ +│ │ - OpenTelemetry processor sends @comapeo/core │ │ +│ │ spans (PR #1051) to Sentry transport │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ │ +│ │ shared DSN/release/env │ +│ ▼ │ +│ ┌─────────────────── Native (FGS) ─────────────────┐ │ +│ │ Android: sentry-android via @sentry/react-native│ │ +│ │ iOS: sentry-cocoa via @sentry/react-native │ │ +│ │ - hard crash reports │ │ +│ │ - we forward NodeJSService ERROR transitions │ │ +│ │ with phase tag for correlation │ │ +│ └──────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +Key splits: + +- **JS and native** share a single `@sentry/react-native` SDK that + the host app installs and initializes. The module never imports + `@sentry/react-native` directly; it accepts a Sentry-shaped + adapter object that the host hands in (see §4.1). +- **Node backend** runs a separate `@sentry/node` SDK, initialized + inside the bundle. Configuration arrives over the existing + control socket, embedded in the `init` frame alongside the + rootkey (see §4.2). The DSN is therefore short-lived in argv-free + memory only. +- **Android FGS process** has no JS bridge but does reach the + same Sentry-android SDK if the host app's `MainApplication` + initializes it before starting the FGS. Cross-process attribution + is via `release`+`environment`+a `proc:fgs` tag, not a shared + client. + +--- + +## 4. Configuration API + +### 4.1 Public JS API + +A new sub-export so the import is explicit and tree-shakable: + +```ts +// File: src/sentry.ts (new) +import type * as SentryReactNative from "@sentry/react-native"; + +export type SentryAdapter = Pick< + typeof SentryReactNative, + | "captureException" + | "captureMessage" + | "startSpan" + | "continueTrace" + | "getActiveSpan" + | "getTraceData" +>; + +export interface ComapeoSentryConfig { + /** + * The host app's already-initialized `@sentry/react-native` + * module (or any object satisfying `SentryAdapter`). The + * module never calls `Sentry.init()` itself; the host app + * has done that with its DSN before this call. + */ + sentry: SentryAdapter; + + /** + * DSN + meta to forward to the Node backend's own + * `@sentry/node` init. Backend runs in its own runtime + * (separate process on Android, same process on iOS) so + * it needs its own SDK boot. Pass `null` to skip + * backend-side Sentry entirely (e.g. you only want JS + * errors). + */ + backend: null | { + dsn: string; + environment?: string; + release?: string; + sampleRate?: number; // error sampling + tracesSampleRate?: number; // span sampling + /** + * Optional hard cap on the size of `request.args` we + * serialize into rpc spans. Defaults to 0 — args are NOT + * captured by default to avoid PII. Set to a small number + * to capture truncated args during debugging. + */ + rpcArgsBytes?: number; + }; +} + +/** + * Wires Sentry into this module. Idempotent and one-shot: + * the first call wins; subsequent calls log a warning. + * + * Must be called *before* the first RPC method on `comapeo` + * is invoked, so that the request-side span wrapper is in + * place. State observers are wired immediately on call. + */ +export function configureSentry(config: ComapeoSentryConfig): void; +``` + +Consumer usage in CoMapeo Mobile (host app): + +```ts +import * as Sentry from "@sentry/react-native"; +import { configureSentry } from "@comapeo/core-react-native/sentry"; + +Sentry.init({ dsn: process.env.SENTRY_DSN, /* ... */ }); + +configureSentry({ + sentry: Sentry, + backend: { + dsn: process.env.SENTRY_DSN, + environment: __DEV__ ? "development" : "production", + release: APP_VERSION, + tracesSampleRate: 0.1, + }, +}); +``` + +Apps that don't want Sentry simply never import +`@comapeo/core-react-native/sentry`. The main barrel +(`@comapeo/core-react-native`) keeps no Sentry imports and the +adapter type is the only thing pulled into typecheck for those +that do opt in. + +### 4.2 Plumbing the backend config + +The Node backend can't read `process.env` from the host RN app — +it's a separate JS runtime. The control socket already carries the +boot handshake; we extend the existing `init` frame: + +```js +// Native → Node +{ + type: "init", + rootKey: "", + sentry: { // new, optional + dsn: "https://…", + environment: "production", + release: "1.4.2", + sampleRate: 1.0, + tracesSampleRate: 0.1, + rpcArgsBytes: 0 + } +} +``` + +Why piggyback on `init` rather than a separate frame: +- Init is already a one-shot, validated frame + (`backend/index.js:57-103`). Adding an optional sibling field + costs ~10 lines of validation. +- Sentry init must complete **before** `MapeoManager` is + constructed (so span context is available for any boot-time + spans we add) and **before** `ComapeoRpcServer.listen` (so the + `onRequestHook` is registered). The current init handler is the + exact moment we need. +- The control socket is already AF_UNIX local; the DSN never + hits the wire outside the device. + +The native side reads the DSN/environment/release from a +platform-specific source. The simplest path: `configureSentry()` +stashes the backend config into `state` (or a sibling), and +the native module reads it back when it builds the `init` frame. +Specifically: + +- Add a new native bridge method `setSentryConfig(json: string)` + that the JS sub-export calls before the rootkey handshake + completes. Native stores it as a property on `NodeJSService`. +- `NodeJSService.sendInit(rootKey)` includes `sentryConfig` in + the payload if set. + +If `configureSentry()` is called too late (after init has been +sent), we fall back to a separate `sentry-init` control frame +sent post-handshake — the backend's RPC server will then +re-register its `onRequestHook` with the configured Sentry +client. Calls already in flight at that moment are not traced +(documented limitation). + +--- + +## 5. Backend instrumentation (`backend/`) + +Mirrors `comapeo-mobile/src/backend/src/app.js`, adapted to this +module's two-socket boot. + +### 5.1 Bundle strategy + +`@sentry/node` becomes a `dependencies` entry of `backend/package.json` +and gets rolled into the bundle. The built backend therefore +contains the SDK whether or not anyone uses it. + +Bundle-size cost: `@sentry/node` core is ~150–250 KB minified + +gzipped depending on integrations imported. Acceptable for the +APK/IPA but not zero. Mitigations: + +- Subpath-import only what we need + (`@sentry/node/init`, `@sentry/core`) rather than the full + default bundle. We do **not** want HTTP / Express / undici + auto-instrumentation in this Node — the only network surface + is the local fastify on 127.0.0.1. +- Exclude OTLP exporters; the only transport we need is the + Sentry HTTPS transport that ships in `@sentry/node`. +- Confirm rollup can tree-shake; if not, the bundle plugin + config in `backend/rollup.config.ts` may need an explicit + `external: []` adjustment. + +A future optimisation if size matters more than build simplicity +(§9.2): produce a second backend bundle with Sentry stripped, and +have the native module pick which assets dir to copy into +`nodejs-project/` based on host-app config. Not in v1. + +### 5.2 `Sentry.init()` location + +In `backend/index.js`, before any other side-effecting import that +might throw and before `controlIpcServer.listen()`: + +```js +// backend/index.js (sketch) +import * as Sentry from "@sentry/node"; + +let sentryActive = false; + +function initSentry(config) { + Sentry.init({ + dsn: config.dsn, + environment: config.environment, + release: config.release, + sampleRate: config.sampleRate ?? 1.0, + tracesSampleRate: config.tracesSampleRate ?? 0, + integrations: [ + // Keep this list explicit — auto-discovery pulls in + // http/express/etc. that we don't want. + Sentry.consoleLoggingIntegration(), + ], + // tag every event so we can split JS vs native vs backend + // in Sentry's UI. + initialScope: { tags: { layer: "backend" } }, + }); + sentryActive = true; +} +``` + +The `init` handler in `controlIpcServer` calls `initSentry` if the +frame includes a `sentry` field, before resolving `initPromise`: + +```js +init: (message) => { + // … existing rootKey validation … + if (message.sentry) { + try { initSentry(message.sentry); } + catch (e) { console.error("Sentry init failed", e); } + } + resolveInit(rootKey); +} +``` + +### 5.3 Error capture wiring + +Three failure surfaces in `backend/index.js` to retrofit: + +1. **`handleFatal(phase, error)`** — already the single funnel for + uncaught exceptions, unhandled rejections, and boot-phase + throws (`listen-control`/`init`/`construct`/`runtime`). Add: + + ```js + if (sentryActive) { + Sentry.captureException(err, { + tags: { phase, layer: "backend" }, + }); + // Ensure the event is flushed before process.exit(1). + await Sentry.flush(100).catch(() => {}); + } + ``` + + The 100 ms flush window aligns with the existing + `broadcastError` flush — both run inside the same + pre-exit window, in parallel. + +2. **`error-native` handler** — frames forwarded from Android + FGS-local failures (rootkey, watchdog) reach `handleFatal` + with the FGS-supplied phase, so they get captured by #1 + automatically. We add a `tags: { source: "native" }` so + Sentry can filter cross-process forwarding. + +3. **Per-RPC errors** — handled in §5.4. + +### 5.4 RPC tracing — server side + +Replicates the `onRequestHook` from +`comapeo-mobile/src/backend/src/app.js`, called from +`backend/lib/comapeo-rpc.js`: + +```js +// backend/lib/comapeo-rpc.js (sketch) +import * as Sentry from "@sentry/node"; + +export class ComapeoRpcServer extends ServerHelper { + constructor(manager, { sentry } = {}) { + super((socket) => { + const messagePort = new SocketMessagePort(socket); + messagePort.start(); + const server = createMapeoServer(manager, messagePort, { + onRequestHook: sentry ? makeSentryRequestHook() : undefined, + }); + messagePort.on("close", () => server.close()); + }); + } +} + +function makeSentryRequestHook() { + return (request, next) => { + const sentryTrace = request.metadata?.["sentry-trace"]; + const baggage = request.metadata?.baggage; + return Sentry.continueTrace({ sentryTrace, baggage }, () => + Sentry.startSpan( + { + op: "rpc", + name: request.method.join("."), + forceTransaction: true, + attributes: { + "rpc.method": request.method.join("."), + // args intentionally omitted unless rpcArgsBytes>0 + }, + }, + async (span) => { + try { + await next(request); + span.setStatus({ code: 1, message: "ok" }); + } catch (error) { + span.setStatus({ code: 2, message: "internal_error" }); + Sentry.captureException(error, { + tags: { layer: "backend", op: "rpc" }, + }); + throw error; + } + }, + ), + ); + }; +} +``` + +Differences from the comapeo-mobile reference: + +- The hook is only registered when Sentry is active; absent + config, `createMapeoServer` is called without + `onRequestHook` and there is zero overhead. +- We rethrow after `captureException` so the IPC error path + still returns a rejection to the JS caller. The reference + swallows it inside `startSpan`'s callback, which silently + resolves the RPC promise — that loses error visibility + on the JS side. +- `request.args` is not serialized by default. In CoMapeo data + the args can be project-scoped content (observation fields, + attachments). PII risk is high, so opt-in only via + `rpcArgsBytes`. + +### 5.5 OpenTelemetry forwarding (PR #1051) + +When `comapeo-core` PR #1051 merges, `@comapeo/core` will emit +OpenTelemetry spans through the global `@opentelemetry/api` +provider. `@sentry/node` v8+ is built on OpenTelemetry: spans +emitted via `@opentelemetry/api` are picked up automatically by +the Sentry span processor. + +Concretely, after `Sentry.init()`, no further wiring is needed — +`@comapeo/core`'s spans become children of the active Sentry +transaction (the RPC span from §5.4) and ship to the configured +DSN. + +If PR #1051 lands before this integration, we should verify the +parent span linkage in a manual smoke test (see §10). + +--- + +## 6. JS / RN module instrumentation (`src/`) + +### 6.1 New files + +- `src/sentry.ts` — public sub-export. Exposes + `configureSentry()`, types, and the wrapped client. +- `src/sentry-internal.ts` — module-private state holding the + active adapter (or `null`), keyed reads for the RPC wrapper. + +The main barrel (`src/index.ts`) is unchanged so consumers who +don't import the sub-export get no Sentry types or runtime code +linked in. + +### 6.2 RPC client tracing — request side + +The existing `comapeo` client is created once at module load: + +```ts +// src/ComapeoCoreModule.ts:71-72 +const messagePort = new CoreMessagePort() as unknown as MessagePort; +export const comapeo: MapeoClientApi = createMapeoClient(messagePort); +``` + +To attach `sentry-trace` + `baggage` headers as `request.metadata` +on outgoing RPC frames, we have two options: + +**Option A — IPC-level metadata factory** (preferred) + +`@comapeo/ipc/client.js` already supports `request.metadata` on +the wire (the server reads it in `onRequestHook`). If +`createMapeoClient` accepts (or can be extended to accept) a +`getMetadata(method)` option, we register one that returns the +current trace headers from the active Sentry adapter: + +```ts +// src/ComapeoCoreModule.ts (changed) +import { activeAdapter } from "./sentry-internal"; + +export const comapeo: MapeoClientApi = createMapeoClient(messagePort, { + getMetadata: () => { + const a = activeAdapter(); + if (!a) return undefined; + // Sentry v8 helper that returns sentry-trace + baggage. + const { "sentry-trace": st, baggage } = a.getTraceData(); + return st ? { "sentry-trace": st, baggage } : undefined; + }, +}); +``` + +Verify whether the installed `@comapeo/ipc` (currently `^8.0.0`) +exposes such a hook. If it doesn't, file an upstream issue and +fall back to Option B for the interim. + +**Option B — Method proxy wrapper** + +`configureSentry` returns a Proxy-wrapped clone of `comapeo` +where each method call: +1. Starts a `Sentry.startSpan({ op: "rpc.client", name: ... })`. +2. Reads `getTraceData()` for headers. +3. Calls the underlying `comapeo` method with a wrapped first + argument that smuggles the headers — but this only works if + the IPC supports per-call metadata, which collapses Option B + into Option A. + +If neither path is possible without an upstream change to +`@comapeo/ipc`, we accept JS-side spans without distributed +tracing for v1 (the backend still produces its own spans, just +unlinked) and pursue the IPC change as a follow-up. + +### 6.3 State observer capture + +`state` already surfaces every error condition the JS layer +sees. `configureSentry()` registers two listeners: + +```ts +state.addListener("stateChange", (s, info) => { + if (s !== "ERROR" || !info) return; + const e = new Error(info.errorMessage); + e.name = `ComapeoError:${info.errorPhase}`; + adapter.captureException(e, { + tags: { + layer: "rn", + "comapeo.phase": info.errorPhase, + "comapeo.state": s, + }, + }); +}); + +state.addListener("messageerror", (err) => { + adapter.captureException(err, { + tags: { layer: "rn", source: "control-socket" }, + level: "warning", + }); +}); +``` + +Phase tags align with the values produced in +`src/ComapeoCore.types.ts` and the native sources +(`rootkey`, `node-runtime-unexpected`, `shutdown-timeout`, +`starting-timeout`, `ipc`, `listen-control`, `init`, +`construct`, `runtime`). They become Sentry filterable tags so +the team can dashboard "rootkey load failure rate" or "FGS +watchdog timeout rate" without parsing message strings. + +### 6.4 Public client error capture + +The IPC client surfaces RPC errors as rejected promises. Most +captures happen on the backend side (§5.4) and reach Sentry from +there with full context. The JS side adds a thin +`captureException` for client-perceived errors (e.g. RPC timeouts, +disconnect mid-call) that the backend never observed: + +```ts +// inside the wrapper or proxy from §6.2 +async (...args) => { + return Sentry.startSpan({ op: "rpc.client", name: method }, async () => { + try { + return await underlying[method](...args); + } catch (e) { + // Only capture if it didn't originate from a backend + // event we already see in §5.4 — the backend tags its + // captures with `layer: "backend"`. Backend RPC failures + // arrive here as plain errors, but Sentry de-dupes if + // the same exception is captured twice with different + // contexts. Acceptable. + Sentry.captureException(e, { + tags: { layer: "rn", op: "rpc.client", "rpc.method": method }, + }); + throw e; + } + }); +} +``` + +--- + +## 7. Native instrumentation (`ios/`, `android/`) + +The host app's `@sentry/react-native` already configures the +underlying `sentry-cocoa` and `sentry-android` SDKs for the main +process. What's left for this module: + +### 7.1 Forwarding the backend Sentry config + +A new bridge method on the Expo module: + +- iOS: `ComapeoCoreModule.swift::Function("setSentryConfig", …)` + → calls `nodeService.setSentryConfig(json)`. +- Android (main app process): same function, forwards the JSON + to the FGS via an Intent extra on `startService`. + +The `NodeJSService` on each platform stores the JSON and +embeds it in the `init` frame (§4.2). + +### 7.2 Android FGS process + +The FGS runs in the `:ComapeoCore` process — see +`ARCHITECTURE.md §2.2`. `Sentry.init()` in the host app's +`MainApplication` runs only in the main process; the FGS process +gets a fresh `Application` and needs its own init. + +Two options: + +1. **Host-app responsibility.** Document that the host app's + `MainApplication.onCreate` should detect the FGS process and + call `SentryAndroid.init(...)` with the same DSN there. + `@sentry/react-native` does not handle multi-process + automatically. +2. **Module convenience.** Add a helper + `ComapeoCoreInit.installSentryInFgs(application, options)` that + the host calls from its `MainApplication`. The helper detects + `getProcessName().endsWith(":ComapeoCore")` and conditionally + inits `SentryAndroid`. + +Option 2 keeps the cross-process detail inside the module that +introduced the second process. Recommended. + +### 7.3 Native error tagging + +When `NodeJSService` enters ERROR locally (rootkey load, +watchdog), it already populates `_lastError` and emits +`stateChange`. The module emits a JS-visible event that §6.3 +captures, but on Android FGS that capture happens in the main +process — the FGS's own context (logcat tail, foreground state, +notification ID) is in the *FGS* process's Sentry scope. + +If the FGS-side Sentry SDK is initialised (§7.2 option 2), we +also call `SentryAndroid.captureException` from the FGS error +handler, tagged `proc:fgs phase:`, before we forward the +`error-native` frame to Node. The duplicate event (FGS-side + +backend-side via `error-native` re-broadcast + main-process JS-side +via `stateChange`) is deduplicated by Sentry's fingerprinting and +gives us all three vantage points. + +iOS doesn't need this — the FGS doesn't exist there, everything +runs in the host app process and the host app's +`@sentry/react-native` already covers it. + +### 7.4 Hard-crash reporting + +Crashes that bypass JS (SIGSEGV in a native addon, OOM kill, +`process.abort()`) are documented in `ARCHITECTURE.md §6` as +"belong in a separate channel". `sentry-cocoa` and +`sentry-android` already handle native crashes for the host app +process; on Android the FGS process needs its own init (§7.2) +to capture FGS-process crashes. + +We do not bundle `sentry-native` into the embedded `nodejs-mobile` +runtime. A V8 abort or libnode crash will not produce a Sentry +event from inside Node — but it will produce an Android-process +crash (since the FGS process dies) which `sentry-android` will +capture with a stacktrace from the JNI side. + +--- + +## 8. PII, sampling, and privacy + +CoMapeo data is sensitive (observation locations, attachments, +device identities). Defaults must avoid leaking it into Sentry: + +- **`request.args` is never serialized** unless + `rpcArgsBytes > 0` is explicitly set. Method names and + metadata only. +- **No project IDs in span names**; only RPC method paths + (`project.observation.create`, etc.). If we later want + per-project breakdowns, hash the project ID before adding + it as a tag. +- **No rootkey, no public/secret keypair, no observation + contents** in event payloads. The `error-native` frame + carries phase + message; the backend `Sentry.captureException` + call does too. +- **Stacktraces** are fine — they may include filenames from + inside `@comapeo/core` and the bundled backend. No user data + unless an `Error.message` was constructed with one (audit + these on integration). +- **`tracesSampleRate`** defaults to `0` if unspecified. The + host app must opt into RPC tracing volume explicitly. +- **`sendDefaultPii`** (Sentry option) is left to the host + app's `Sentry.init()` and the backend init we forward; we + don't override it. + +A pre-merge checklist (§10) includes a `before_send` hook that +greps every outbound event for known sensitive substrings +(`rootKey`, `dsn`, base64-shaped 22-char strings) as a +defense-in-depth check during integration smoke tests. + +--- + +## 9. Phasing + +### 9.1 Phase 1 — error capture only (smallest delivery) + +- `configureSentry({ sentry, backend: null })` valid: only + JS-side errors via `state` listeners (§6.3). No backend + changes, no IPC changes, no bundle delta. +- Ship as `@comapeo/core-react-native/sentry` sub-export. +- Host app (CoMapeo Mobile) calls it after `Sentry.init`. + +Value: immediate visibility into rootkey failures, watchdog +timeouts, IPC errors, and `messageerror` parse failures — +the most actionable production failures we have today. + +Cost: ~50 LOC in `src/sentry.ts`, no native or backend +changes, zero risk to other consumers. + +### 9.2 Phase 2 — backend error capture + RPC tracing + +- Add `@sentry/node` to `backend/package.json`, bundle it. +- Extend `init` frame with optional `sentry` field. +- `handleFatal` and `onRequestHook` wired (§5.3, §5.4). +- iOS/Android `setSentryConfig` bridge methods (§7.1). +- Client-side `getMetadata` (§6.2) for distributed tracing + (or accept JS-side spans without parent linkage if + `@comapeo/ipc` doesn't yet support it — track upstream). + +Value: RPC method-level errors and durations in Sentry; +backend boot failures with proper stacktraces; baseline +distributed tracing. + +Cost: ~200 LOC across backend, JS, and native; ~150–250 KB +bundle delta on every consumer (mitigations in §5.1). + +### 9.3 Phase 3 — Android FGS-process Sentry + +- `installSentryInFgs(application, options)` helper (§7.2). +- Document the multi-process init pattern in README. + +Value: FGS-process hard crashes and FGS-local errors get +process-tagged Sentry events with FGS-context breadcrumbs. + +### 9.4 Phase 4 — `@comapeo/core` OpenTelemetry forwarding + +- Bump `@comapeo/core` once PR #1051 lands. +- Verify Sentry's OTel integration picks up the spans + with the RPC transaction as parent. +- Document any required tracing-config overrides. + +Value: deep traces inside core operations (sync, indexing, +hypercore) — the data Sentry's performance tab is designed +to surface. + +### 9.5 Phase 5 — refinements + +- Tune sample rates from production data. +- Add structured breadcrumbs for state transitions (so + pre-error context shows the boot sequence). +- Optional: dual backend bundles for Sentry-free consumers + if bundle size becomes a concern. + +--- + +## 10. Test plan + +### 10.1 Unit / integration + +- `src/sentry.ts` accepts a fake adapter; assert + `captureException` is called for synthetic ERROR + `stateChange` events with the correct phase tag. +- `src/sentry.ts` is a no-op if `configureSentry` was never + called: the existing `comapeo` client should produce + identical wire frames (no `metadata` injected). +- Backend: build the bundle without a `sentry` field in + `init` and confirm `Sentry.init` is never called. + Build with the field and confirm `onRequestHook` is + registered (assert via metadata propagation). + +### 10.2 Manual smoke + +- Run the example app with a temporary DSN (a test Sentry + project). Trigger: + - A deliberate JS-side throw inside a `comapeo.*` callback + → JS-layer event in Sentry. + - A backend throw via a debug RPC method → backend-layer + event with parent transaction. + - An Android FGS rootkey-store corruption (delete the + keystore alias) → ERROR event with `phase:rootkey` + from both FGS-process and main-process scopes. + - A node abort (`process.abort()` via a debug RPC) → + `sentry-android` native crash event. +- Confirm no PII in events: open each event, scan for + base64-shaped 22-char strings, file paths under + `Application Support`, project secrets. +- Confirm distributed trace shows JS-client span → backend + RPC transaction → (with PR #1051) core operation spans. + +### 10.3 Regression + +- Run the existing `e2e/run-instrumented-tests.sh` and the + iOS `swift test` / `xcodebuild test` suite with + `configureSentry` *not* called → no behaviour change. +- Build size delta tracked: compare `android/src/main/assets/nodejs-project/` + bundle size before and after Phase 2. + +--- + +## 11. Open questions + +1. **Does `@comapeo/ipc@^8` support a client-side `getMetadata` + hook?** §6.2 hinges on this. If not, what's the upstream + path — patch + release, or temporary monkey-patch in this + module? +2. **Sentry SDK version**: pin to `@sentry/react-native@^6` and + `@sentry/node@^8`? The OpenTelemetry-first model only exists + in v8+ for Node and v6+ for React Native; older versions + force a different tracing API. +3. **Bundle size budget**: do we have a hard limit for the + embedded backend? §5.1 estimates suggest ~150–250 KB; if the + budget is tighter, plan for dual bundles in Phase 5. +4. **Release tagging**: how does `release` flow from the host + app (CoMapeo Mobile) into the backend? The natural source is + the host app's `package.json` version, but the backend bundle + is built inside this module — we'd need to surface the value + via the runtime config rather than baking it in at build time. +5. **Cross-process scope on Android**: Phase 3 assumes the FGS's + Sentry events can carry a `proc:fgs` tag. Confirm the host + app's `@sentry/react-native` config doesn't override our tag + in the main-process events. + +--- + +## 12. Summary of file changes + +Concrete touch list, by phase, for code review. + +**Phase 1** + +- `src/sentry.ts` (new) — `configureSentry`, types, state listeners. +- `src/sentry-internal.ts` (new) — module-private adapter holder. +- `package.json` — add `@sentry/react-native` to `peerDependencies` + with `peerDependenciesMeta.optional: true`. +- `docs/sentry-integration-plan.md` (this file). + +**Phase 2** + +- `backend/package.json` — `@sentry/node` dependency. +- `backend/index.js` — `initSentry`, hook `handleFatal`, extend + `init` handler. +- `backend/lib/comapeo-rpc.js` — accept `sentry` option, register + `onRequestHook`. +- `src/ComapeoCoreModule.ts` — pass `getMetadata` to + `createMapeoClient` (or wrapper fallback). +- `ios/ComapeoCoreModule.swift`, `ios/NodeJSService.swift` — + `setSentryConfig` and embed in `init` frame. +- `android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt`, + `NodeJSService.kt` — same on Android, plus FGS Intent extra. + +**Phase 3** + +- `android/src/main/java/com/comapeo/core/ComapeoCoreInit.kt` + (new) — FGS-side Sentry init helper. +- README — document FGS init pattern for host apps. + +**Phase 4** + +- `backend/package.json` — bump `@comapeo/core` once PR #1051 + ships. +- Smoke test verification, no code changes expected. + +--- From fd33ffca7becc53a0eb0d2228809ed8f1f5b7d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 06:28:45 +0000 Subject: [PATCH 02/33] docs(sentry-plan): config plugin, native telemetry, capture toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the FGS-cold-start gap where the prior draft required RN to be alive before backend Sentry could initialize: - §4 reworked: Expo config plugin writes DSN/environment/release into Android manifest meta-data and iOS Info.plist at prebuild time. Native reads those at process start, no JS round-trip, before booting @sentry/node and @sentry/android. - §7.4 added: native telemetry data design mapped onto Sentry primitives (breadcrumbs for state transitions, transaction + spans for boot/shutdown phases, captureMessage for timeouts, tags/contexts for cross-process attribution). Categorizes captures as essential vs opt-in and documents a hard never-capture list for PII. - §9 added: persisted "capture application data" toggle with restart-to-activate semantics. Snapshot read at boot, embedded in the init frame; gates per-RPC spans, sync-session transactions, memory checkpoints, and storage-size sampling. Never unlocks the never-capture list. - §10 phasing and §13 file-change list updated. New open questions added for release tagging, plugin no-op behavior, toggle UI, and boot sample rate. https://claude.ai/code/session_01EcVXzczA1TVkhEkgUg9DKX --- docs/sentry-integration-plan.md | 949 ++++++++++++++++++++++++++------ 1 file changed, 790 insertions(+), 159 deletions(-) diff --git a/docs/sentry-integration-plan.md b/docs/sentry-integration-plan.md index 9df4b1e..952eebb 100644 --- a/docs/sentry-integration-plan.md +++ b/docs/sentry-integration-plan.md @@ -141,10 +141,12 @@ Key splits: `@sentry/react-native` directly; it accepts a Sentry-shaped adapter object that the host hands in (see §4.1). - **Node backend** runs a separate `@sentry/node` SDK, initialized - inside the bundle. Configuration arrives over the existing - control socket, embedded in the `init` frame alongside the - rootkey (see §4.2). The DSN is therefore short-lived in argv-free - memory only. + inside the bundle. Configuration is read at native process start + from build-time-baked sources (Android manifest meta-data, iOS + Info.plist) seeded by an Expo config plugin (§4.2), and forwarded + to the backend in the existing `init` control-socket frame. This + avoids any JS round-trip on the boot path so the FGS can + cold-start without RN being alive. - **Android FGS process** has no JS bridge but does reach the same Sentry-android SDK if the host app's `MainApplication` initializes it before starting the FGS. Cross-process attribution @@ -153,11 +155,188 @@ Key splits: --- -## 4. Configuration API +## 4. Configuration + +### 4.0 The cold-start constraint + +Earlier drafts of this plan plumbed the backend Sentry config from +JS through the control-socket `init` frame. That has a real cost: + +1. **FGS cold-start (Android).** The `:ComapeoCore` foreground + service can be cold-launched by the system to deliver a sync + trigger *before* the host app's RN bridge is alive. With a + JS-driven config, the FGS would have to either start the + backend with Sentry off (losing observability for the most + interesting code path — boot-time errors during a cold sync) + or block on RN to come up first (defeats the purpose of an + FGS-survives-RN architecture). +2. **Boot latency on every launch.** Even when RN is alive, the + JS round-trip for `setSentryConfig(...)` adds a serial step + to the boot sequence. The backend can't sample `boot.listen` + or `boot.construct` spans until after RN is ready and has + called `configureSentry`. +3. **State observability gap.** `state.getState()` reflects only + transitions captured *after* the JS listener is attached. + Errors that fire before `configureSentry` runs (rootkey load + races, FGS-side watchdog timeouts) miss Sentry entirely under + the JS-driven model. + +Three configuration vectors solve this together: + +| Vector | When read | Purpose | +|---|---|---| +| **Expo config plugin** (build-time) | At native process start, before any IPC | DSN, environment, release, sample rates. The single source of truth. | +| **Persisted native preference** (runtime, restart-to-activate) | At native process start | The "capture application data" toggle (§9). | +| **JS adapter handoff** (`configureSentry`) | When RN bridge is up | Hands the host app's already-initialized `@sentry/react-native` to this module so JS-side listeners can call `captureException` / `startSpan`. Does **not** carry DSN. | + +### 4.1 Build-time: Expo config plugin (primary) + +A new plugin shipped from this module — `app.plugin.js` at the +package root, registered in `expo-module.config.json`. It uses +the same `@expo/config-plugins` patterns already in use for +`apps/example/plugins/with-android-tests/index.js`. + +Consumer registration in CoMapeo Mobile's `app.json` / +`app.config.ts`: + +```json +{ + "expo": { + "plugins": [ + [ + "@comapeo/core-react-native", + { + "sentry": { + "dsn": "https://abc@sentry.example.com/1", + "environment": "production", + "release": "1.4.2", + "tracesSampleRate": 0.1, + "rpcArgsBytes": 0 + } + } + ] + ] + } +} +``` -### 4.1 Public JS API +The plugin runs at `expo prebuild` and writes: + +**Android — `` meta-data in `AndroidManifest.xml`** via +`withAndroidManifest`: + +```xml + + + + + +``` -A new sub-export so the import is explicit and tree-shakable: +These meta-data live on the manifest's main `` tag so +**both the main process and the `:ComapeoCore` FGS process see +them** — `PackageManager.getApplicationInfo(...).metaData` is +shared across processes within the package. + +**iOS — keys in `Info.plist`** via `withInfoPlist`: + +```xml +ComapeoCoreSentryDsn +https://abc@sentry.example.com/1 +ComapeoCoreSentryEnvironment +production +ComapeoCoreSentryRelease +1.4.2 +ComapeoCoreSentryTracesSampleRate +0.1 +ComapeoCoreSentryRpcArgsBytes +0 +``` + +Plugin behaviour rules: + +- If the consumer registers the plugin without a `sentry` key, no + meta-data / Info.plist entries are written. Native treats the + absence as "Sentry off". The example app under `apps/example/` + ships unconfigured. +- If the consumer registers the plugin **with** a `sentry` key, + exactly the keys provided are written. Missing optional fields + (e.g. `environment`) result in absent manifest entries, which + native maps to `null` in the loaded `SentryConfig`. +- Plugin code is small (~50 LOC) and lives alongside the existing + `app.plugin.js` patterns. The plugin is consumed at `expo + prebuild` time only — runtime code path doesn't touch it. +- The DSN is now embedded in the host app's APK/IPA. That's an + accepted tradeoff: Sentry DSNs are not high-secret values + (they identify a project, not authenticate writes; rate + limiting and per-project ingest are server-side). They appear + in stripped binaries of every Sentry-using app. + +### 4.2 Native config consumption + +At native process start (FGS `onCreate` on Android, app delegate +init on iOS), the module loads the manifest / plist keys into a +typed `SentryConfig?` and propagates it two ways: + +```kotlin +// android/.../SentryConfigStore.kt (new) — sketch +data class SentryConfig( + val dsn: String, + val environment: String?, + val release: String?, + val sampleRate: Double?, + val tracesSampleRate: Double?, + val rpcArgsBytes: Int?, +) + +fun loadFromManifest(ctx: Context): SentryConfig? { + val meta = ctx.packageManager.getApplicationInfo( + ctx.packageName, PackageManager.GET_META_DATA + ).metaData ?: return null + val dsn = meta.getString("com.comapeo.core.sentry.dsn") ?: return null + return SentryConfig( + dsn = dsn, + environment = meta.getString("com.comapeo.core.sentry.environment"), + release = meta.getString("com.comapeo.core.sentry.release"), + sampleRate = meta.getString("com.comapeo.core.sentry.sampleRate")?.toDoubleOrNull(), + tracesSampleRate = meta.getString("com.comapeo.core.sentry.tracesSampleRate")?.toDoubleOrNull(), + rpcArgsBytes = meta.getString("com.comapeo.core.sentry.rpcArgsBytes")?.toIntOrNull(), + ) +} +``` + +The loaded `SentryConfig` is consumed in two places: + +1. **Native SDK init (Android FGS process).** `SentryAndroid.init(ctx) + { options -> options.dsn = config.dsn; ... }` in the FGS + `Application.onCreate`. Allows the FGS process to capture native + crashes, ANRs, and the §7.4 telemetry events with the same DSN. + On iOS the host app's `@sentry/react-native` already owns the + single-process SDK; we don't re-init. +2. **Backend init frame.** When `NodeJSService.sendInit(rootKey)` + builds the `init` frame, it embeds `SentryConfig` as a + `sentry` field (see §4.5). The backend `Sentry.init()`s + synchronously inside the existing `init` handler, before + `MapeoManager` is constructed and before + `ComapeoRpcServer.listen` registers `onRequestHook`. No JS + round-trip; the FGS-cold-start path is fully covered. + +This is the key change vs. the prior draft: **the backend boot +sequence does not depend on RN being alive to be observable**. + +### 4.3 JS adapter handoff + +JS-side listeners (§6) need a callable Sentry object — `startSpan`, +`captureException`, `getTraceData`. Because the host app already +runs `Sentry.init()` from `@sentry/react-native` (which reads the +same Info.plist / manifest values via its own auto-config), +`configureSentry` exists purely to hand that initialized client +to this module: ```ts // File: src/sentry.ts (new) @@ -171,129 +350,89 @@ export type SentryAdapter = Pick< | "continueTrace" | "getActiveSpan" | "getTraceData" + | "addBreadcrumb" >; export interface ComapeoSentryConfig { /** * The host app's already-initialized `@sentry/react-native` - * module (or any object satisfying `SentryAdapter`). The - * module never calls `Sentry.init()` itself; the host app - * has done that with its DSN before this call. + * (or any object satisfying `SentryAdapter`). The module + * never calls `Sentry.init()`; the host app does, and the + * native SDK is initialized from manifest/plist values + * written by the config plugin. */ sentry: SentryAdapter; - - /** - * DSN + meta to forward to the Node backend's own - * `@sentry/node` init. Backend runs in its own runtime - * (separate process on Android, same process on iOS) so - * it needs its own SDK boot. Pass `null` to skip - * backend-side Sentry entirely (e.g. you only want JS - * errors). - */ - backend: null | { - dsn: string; - environment?: string; - release?: string; - sampleRate?: number; // error sampling - tracesSampleRate?: number; // span sampling - /** - * Optional hard cap on the size of `request.args` we - * serialize into rpc spans. Defaults to 0 — args are NOT - * captured by default to avoid PII. Set to a small number - * to capture truncated args during debugging. - */ - rpcArgsBytes?: number; - }; } /** - * Wires Sentry into this module. Idempotent and one-shot: - * the first call wins; subsequent calls log a warning. + * Hand off the host app's Sentry adapter so this module's JS + * listeners can call into it. Idempotent and one-shot. + * + * Must be called before the first `comapeo.*` RPC if you want + * client-side spans on those calls. State observers attach + * immediately on call. * - * Must be called *before* the first RPC method on `comapeo` - * is invoked, so that the request-side span wrapper is in - * place. State observers are wired immediately on call. + * Note: this does NOT configure DSN/environment/release. Those + * are baked into native config at build time by the Expo plugin + * and read by both `@sentry/react-native` (in the main process) + * and the embedded backend (in the FGS or iOS app process). */ export function configureSentry(config: ComapeoSentryConfig): void; ``` -Consumer usage in CoMapeo Mobile (host app): +Consumer usage in CoMapeo Mobile: ```ts import * as Sentry from "@sentry/react-native"; import { configureSentry } from "@comapeo/core-react-native/sentry"; -Sentry.init({ dsn: process.env.SENTRY_DSN, /* ... */ }); +// Sentry SDK reads DSN from Info.plist / manifest; the plugin +// wrote those values at build time. +Sentry.init({ /* override options if needed */ }); -configureSentry({ - sentry: Sentry, - backend: { - dsn: process.env.SENTRY_DSN, - environment: __DEV__ ? "development" : "production", - release: APP_VERSION, - tracesSampleRate: 0.1, - }, -}); +configureSentry({ sentry: Sentry }); ``` -Apps that don't want Sentry simply never import -`@comapeo/core-react-native/sentry`. The main barrel -(`@comapeo/core-react-native`) keeps no Sentry imports and the -adapter type is the only thing pulled into typecheck for those -that do opt in. +Apps that don't want Sentry don't import the sub-export and don't +register the plugin. The main barrel +(`@comapeo/core-react-native`) keeps no Sentry imports; the only +typecheck-time pull-in for opt-in consumers is the +`SentryAdapter` type. + +### 4.4 Runtime opt-in toggle (forward reference) + +A persisted "capture application data" boolean lives in native +preferences. It gates the *additional* observability surface +described in §7.4 (per-RPC method spans, sync session spans, +counts) but never touches DSN/environment/release and never +unlocks PII fields. See §9 for full design. -### 4.2 Plumbing the backend config +### 4.5 Control-socket payload (internal) -The Node backend can't read `process.env` from the host RN app — -it's a separate JS runtime. The control socket already carries the -boot handshake; we extend the existing `init` frame: +For completeness — the `init` frame written by native to the +backend now carries an optional `sentry` field: ```js -// Native → Node +// Native → Node, on control.sock { type: "init", rootKey: "", - sentry: { // new, optional + sentry: { dsn: "https://…", environment: "production", release: "1.4.2", sampleRate: 1.0, tracesSampleRate: 0.1, - rpcArgsBytes: 0 + rpcArgsBytes: 0, + captureApplicationData: false // §9 toggle, snapshot at boot } } ``` -Why piggyback on `init` rather than a separate frame: -- Init is already a one-shot, validated frame - (`backend/index.js:57-103`). Adding an optional sibling field - costs ~10 lines of validation. -- Sentry init must complete **before** `MapeoManager` is - constructed (so span context is available for any boot-time - spans we add) and **before** `ComapeoRpcServer.listen` (so the - `onRequestHook` is registered). The current init handler is the - exact moment we need. -- The control socket is already AF_UNIX local; the DSN never - hits the wire outside the device. - -The native side reads the DSN/environment/release from a -platform-specific source. The simplest path: `configureSentry()` -stashes the backend config into `state` (or a sibling), and -the native module reads it back when it builds the `init` frame. -Specifically: - -- Add a new native bridge method `setSentryConfig(json: string)` - that the JS sub-export calls before the rootkey handshake - completes. Native stores it as a property on `NodeJSService`. -- `NodeJSService.sendInit(rootKey)` includes `sentryConfig` in - the payload if set. - -If `configureSentry()` is called too late (after init has been -sent), we fall back to a separate `sentry-init` control frame -sent post-handshake — the backend's RPC server will then -re-register its `onRequestHook` with the configured Sentry -client. Calls already in flight at that moment are not traced -(documented limitation). +The backend `init` handler (`backend/index.js`) calls +`initSentry(message.sentry)` before resolving `initPromise`. The +DSN is therefore short-lived in process memory only; not in argv, +not in env, not on disk past the manifest read. --- @@ -634,17 +773,29 @@ The host app's `@sentry/react-native` already configures the underlying `sentry-cocoa` and `sentry-android` SDKs for the main process. What's left for this module: -### 7.1 Forwarding the backend Sentry config - -A new bridge method on the Expo module: - -- iOS: `ComapeoCoreModule.swift::Function("setSentryConfig", …)` - → calls `nodeService.setSentryConfig(json)`. -- Android (main app process): same function, forwards the JSON - to the FGS via an Intent extra on `startService`. - -The `NodeJSService` on each platform stores the JSON and -embeds it in the `init` frame (§4.2). +### 7.1 Loading config and forwarding to the backend + +Native reads `SentryConfig` from the manifest / Info.plist +(§4.2) at process start. There is no JS bridge call required; +config is in place before RN can boot. + +- **iOS**: `AppLifecycleDelegate.application(_:didFinishLaunchingWithOptions:)` + reads `Bundle.main.infoDictionary` and stores `sentryConfig` on + `NodeJSService` before `runNode()`. +- **Android (FGS)**: `ComapeoCoreService.onCreate` reads + `packageManager.getApplicationInfo(...).metaData` and stores + `sentryConfig` on `NodeJSService` before `start()`. +- **Android (main process)**: reads the same metaData when the + `ComapeoCoreModule` first instantiates, used only for the + control-IPC observer to add §7.4 breadcrumbs/events from the + main process. The main-process Sentry SDK is already + initialized by `@sentry/react-native` reading the same values + via its own pathway — we don't re-init. + +The stored config is embedded in the `init` frame +(§4.5) when `NodeJSService.sendInit(rootKey)` runs. The +runtime opt-in toggle (§9) is read from native preferences at the +same moment and merged into the same payload. ### 7.2 Android FGS process @@ -669,28 +820,217 @@ Two options: Option 2 keeps the cross-process detail inside the module that introduced the second process. Recommended. -### 7.3 Native error tagging +### 7.3 Native error tagging — see §7.4.7 + +The cross-process error attribution detail moved into §7.4.7 +alongside the rest of the native telemetry data design. + +### 7.4 Native telemetry data design + +This is the heart of the native instrumentation. Sentry has a +small set of primitives, each suited to different kinds of data. +We design the captures around them rather than dumping logs: + +| Sentry primitive | Use for | Example | +|---|---|---| +| **Breadcrumb** | Lightweight ordered context — what led up to an event. Cheap, capped at ~100 by default, attached to the next event. | "state STARTING→STARTED at t+312ms", "ipc connected", "FGS notification posted" | +| **Transaction** (root span) | A timed unit of work with a clear start/end and a name. Indexed; dashboards can chart durations and counts. | `comapeo.boot` (start→started), `comapeo.shutdown` (stop→stopped) | +| **Span** (child) | A nested timed sub-step inside a transaction. | `boot.listen-control`, `boot.init`, `boot.construct`, `boot.ipc-connect` | +| **Event** (`captureMessage` / `captureException`) | A discrete error or notable occurrence; full stacktrace + context. | rootkey load failure, watchdog timeout fired, FGS killed by OS | +| **Tag** | Indexed key/value pair on events — used for dashboard filtering. | `phase:rootkey`, `proc:fgs`, `comapeo.state:ERROR`, `platform:android` | +| **Context** (custom) | Structured but non-indexed — appears on event detail pages. | `{"comapeo": {"abi": "arm64-v8a", "nodejs_mobile_version": "...", "ipc_socket_age_ms": 1234}}` | +| **User** (anonymized) | A stable but non-identifying user/session id. | host-app-supplied install ID; never the rootkey | + +The remainder of this section walks through what each layer of +the native architecture (state machine, boot phases, timeouts, +IPC, FGS lifecycle) maps onto. + +#### 7.4.1 State transitions → breadcrumbs + +Every `ComapeoState` transition (`STOPPED`/`STARTING`/`STARTED`/ +`STOPPING`/`ERROR`) is captured as a breadcrumb on both the +FGS-side and main-process Sentry scopes: + +```kotlin +// android/.../NodeJSService.kt (FGS), inside the state-derivation +// callsite that already runs deriveState(...) +Sentry.addBreadcrumb(Breadcrumb().apply { + category = "comapeo.state" + level = if (newState == STARTED || newState == STOPPED) + SentryLevel.INFO + else if (newState == ERROR) + SentryLevel.ERROR + else SentryLevel.INFO + message = "$oldState → $newState" + setData("from", oldState.name) + setData("to", newState.name) + setData("backendState", backendState.javaClass.simpleName) + setData("nodeRuntime", nodeRuntime.javaClass.simpleName) + setData("stopRequested", stopRequested) +}) +``` + +These never trigger an upload by themselves — they ride along +on the next event. When something does fire (an ERROR transition, +a captured exception), the dashboard shows the last ~30 seconds of +state history leading up to it. That's exactly the data needed +to debug "why did this end up in ERROR" questions. Always-on. + +#### 7.4.2 Boot as a transaction with phase spans + +Boot is the single most error-prone path in the system. We model +it as a Sentry transaction that spans from `start()` to either +`STARTED` or `ERROR`: + +``` +Transaction: comapeo.boot (op = "boot") +├─ Span: boot.listen-control +├─ Span: boot.ipc-connect (control) +├─ Span: boot.rootkey-load (FGS only) +├─ Span: boot.init (rootkey handshake) +├─ Span: boot.construct (MapeoManager + RPC bind) +└─ Span: boot.ipc-connect (comapeo) +``` + +Each phase corresponds to a stage already named in +`backend/index.js` (the catch tags `phase` on errors with these +exact strings). On the native side, each phase is bracketed by +the existing log calls — we just add `Sentry.startSpan` around +them. Phases that throw set the span status to `internal_error` +and capture the exception; phases that succeed set `ok`. + +The transaction is **always-on essential telemetry**: durations +at boot are first-class signal for performance regressions +(rootkey load took 2s instead of 50ms? new device security +hardware quirk). Native sample rate is independent of +`tracesSampleRate` — we sample boot at 100% even when +`tracesSampleRate=0.01` for RPC because boot fires once per +process and is high-value. Implemented via +`Sentry.startSpan({ ..., forceTransaction: true })` and a +dedicated boot-tag inspected by an event processor that lifts +its sample rate to 1.0. + +#### 7.4.3 Shutdown as a transaction + +Symmetric: `comapeo.shutdown` transaction from `stop()` to +final `STOPPED` (or `ERROR` if shutdown timed out). Spans +for `shutdown.broadcast-stopping`, `shutdown.close-rpc`, +`shutdown.node-join`. Surfaces the difference between graceful +shutdowns (under the 10 s budget) and watchdog-killed ones. + +#### 7.4.4 Timeouts → events (always) + +Every timeout enumerated in `ARCHITECTURE.md §5.7` becomes a +Sentry event when it fires, tagged with which timeout it was: + +| Timeout | Sentry shape | Tags | +|---|---|---| +| iOS `startupTimeout` (30s) | `captureMessage("comapeo: startup timeout fired")` `level=error` | `timeout:startup, platform:ios, layer:native` | +| iOS `stop(timeout:)` | `captureMessage("comapeo: stop timeout fired")` `level=warning` | `timeout:shutdown, platform:ios` | +| iOS `waitForFile` | `captureMessage("comapeo: waitForFile timeout")` `level=error` | `timeout:waitForFile, socket:` | +| iOS `connectWithRetry` exhausted | event with `attempts` context | `timeout:connectRetry` | +| Android `startupTimeoutMs` (30s) | `captureMessage(...)` `level=error` | `timeout:startup, platform:android, proc:fgs` | +| Android FGS `withTimeout` (10s) on stop | `captureMessage(...)` `level=error` | `timeout:fgsStop, proc:fgs` | +| Android `SEND_ERROR_NATIVE_TIMEOUT_MS` (2s) | breadcrumb + `level=warning` event | `timeout:errorNativeForward` | +| Android `waitForFile` (30s) | `captureMessage(...)` `level=error` | `timeout:waitForFile` | + +Timeouts are the most actionable signal for "something is +silently broken" — they always fire something we never want +to pre-emptively recover from. Always-on essential telemetry. + +#### 7.4.5 IPC connection lifecycle → breadcrumbs + events + +`NodeJSIPC.State` transitions +(`Connecting`/`Connected`/`Disconnecting`/`Disconnected`/`Error`) +become breadcrumbs at `category: "comapeo.ipc"`. Disconnects from +a `Connected` state in non-stopping conditions also fire an +event tagged `ipc.unexpected_disconnect:true` with the +pre-disconnect JS state — that's the path that derives `ERROR` +phase `node-runtime-unexpected` (`ARCHITECTURE.md §5.4`), +useful to surface separately from controlled disconnects. + +#### 7.4.6 FGS lifecycle → breadcrumbs + +Android-only: the `ComapeoCoreService` lifecycle hooks +(`onCreate`, `onStartCommand`, `onTaskRemoved`, `onDestroy`) and +notification post/cancel become breadcrumbs at +`category: "comapeo.fgs"`. FGS-killed-by-OS scenarios (the FGS +process dies without `onDestroy` running) appear in +`sentry-android`'s session-replay-style detection if it's +enabled — we don't add custom code for that. + +#### 7.4.7 Native error tagging (was §7.3) When `NodeJSService` enters ERROR locally (rootkey load, watchdog), it already populates `_lastError` and emits -`stateChange`. The module emits a JS-visible event that §6.3 -captures, but on Android FGS that capture happens in the main -process — the FGS's own context (logcat tail, foreground state, -notification ID) is in the *FGS* process's Sentry scope. - -If the FGS-side Sentry SDK is initialised (§7.2 option 2), we -also call `SentryAndroid.captureException` from the FGS error -handler, tagged `proc:fgs phase:`, before we forward the +`stateChange`. The JS-visible capture happens in §6.3, but on +Android FGS that capture lands in the *main* process — the +FGS's own context (logcat tail, foreground state, notification +ID) is in the *FGS* process's Sentry scope. + +If the FGS-side Sentry SDK is initialised (§4.2), we also call +`Sentry.captureException` from the FGS error handler, tagged +`proc:fgs phase:`, **before** forwarding the `error-native` frame to Node. The duplicate event (FGS-side + -backend-side via `error-native` re-broadcast + main-process JS-side -via `stateChange`) is deduplicated by Sentry's fingerprinting and -gives us all three vantage points. +backend-side via `error-native` re-broadcast + main-process +JS-side via `stateChange`) is deduplicated by Sentry's +fingerprinting; the three captures together carry the FGS +context, the backend stack, and the main-process state-machine +trail. iOS doesn't need this — the FGS doesn't exist there, everything runs in the host app process and the host app's `@sentry/react-native` already covers it. -### 7.4 Hard-crash reporting +#### 7.4.8 Categorization: essential vs opt-in + +| Capture | Tier | Rationale | +|---|---|---| +| State transition breadcrumbs | **Essential** | Cheap, ride on existing events. Required to debug ERROR paths. | +| Boot transaction + phase spans | **Essential** | Once-per-process, high-value perf signal. Forced 100% sample. | +| Shutdown transaction + phase spans | **Essential** | Same reasoning — once-per-process. | +| Timeout events | **Essential** | Always actionable; never silent recovery. | +| ERROR `captureException` (FGS, backend, main) | **Essential** | Already fires; this plan just structures it. | +| IPC connection breadcrumbs | **Essential** | Cheap; required to attribute disconnect-derived ERROR. | +| Unexpected-disconnect event | **Essential** | High-signal failure mode. | +| FGS lifecycle breadcrumbs | **Essential** | Cheap; required to debug FGS-killed-by-OS scenarios. | +| Per-RPC method spans (sampled) | **Opt-in** (capture application data on) | High volume; usable for performance dashboards but only when the user consented. | +| Sync session transaction (start → ready → finish, with peer count) | **Opt-in** | Reveals usage cadence. Counts only — no peer identities. | +| Background/foreground transitions | **Opt-in** | Reveals usage patterns. | +| Backend memory/heap snapshots (periodic) | **Opt-in** | Cost is non-trivial; only needed for memory-leak hunts. | +| Storage size of `privateStorageDir` (periodic) | **Opt-in** | Dataset-size signal. | + +#### 7.4.9 Hard never-capture list + +Independent of any toggle, these are off by construction — +not behind a config option, not behind `rpcArgsBytes>0`, not +ever: + +- The 16-byte rootkey, in any encoding. +- Identity public/secret keypairs derived from the rootkey. +- Observation contents (text, attachments, attachment paths). +- Precise location (lat/lng). If we ever want geographic + distribution data, it goes through quantization to + ~country/region resolution at the host-app layer, never + here. +- User-entered text from any settings UI. +- Project IDs in raw form. If included as a tag, must be + hashed (SHA-256, truncated to 16 chars) at capture site. +- Peer device identities or discovered peer counts above + bucketed thresholds (e.g. record `peers_bucket: 1-3 / 4-10 / 10+`, + not raw counts). +- File paths under `Application Support` or + `getFilesDir()` that include the rootkey or project IDs. + +A `before_send` event processor enforces the list +defensively: it walks the event tree for known sensitive +substrings (`rootKey`, base64-shaped 22-char strings, `lat=`, +`lng=`, `latitude:`, `longitude:`) and either redacts or +drops the event. This is belt-and-suspenders — the fix is +always at the capture site, but the processor catches +mistakes before they ship. + +### 7.5 Hard-crash reporting Crashes that bypass JS (SIGSEGV in a native addon, OOM kill, `process.abort()`) are documented in `ARCHITECTURE.md §6` as @@ -740,29 +1080,220 @@ defense-in-depth check during integration smoke tests. --- -## 9. Phasing +## 9. Runtime "capture application data" toggle + +A persisted boolean preference, off by default, that the host +app's settings UI exposes to the end user. When on, the +**opt-in** captures from §7.4.8 are emitted; when off (the +default), only the essential captures are. Crucially, this +never unlocks anything in the §7.4.9 never-capture list — the +two layers are independent. + +### 9.1 Persistence + +A native preference, written and read entirely on the native +side so it survives app uninstall-resistant in the same way +existing user prefs do (and is not a special concern at the +backup/restore layer): + +- **Android**: stored in + `EncryptedSharedPreferences("comapeo-core-prefs", ...)` — + the same `androidx.security.crypto` mechanism used elsewhere + in the module. Key: `sentry.captureApplicationData`. Read by + both the main process and the FGS process. +- **iOS**: stored in `UserDefaults.standard` keyed + `com.comapeo.core.sentry.captureApplicationData`. Read at + app delegate init. + +### 9.2 JS API + +The toggle is exposed alongside `configureSentry`: + +```ts +// File: src/sentry.ts (additions) +/** + * Read the persisted opt-in flag. Resolves with the + * current native-side value. Reads are sync-fast on both + * platforms but the API is async to match the bridge. + */ +export function getCaptureApplicationData(): Promise; + +/** + * Write the persisted opt-in flag. Returns when the write has + * been durably committed on the native side. + * + * IMPORTANT: the new value does NOT take effect until the next + * app launch. The current process keeps emitting whatever it + * was emitting at boot. This is documented in the host app's + * settings UI so the user knows to restart for the change to + * apply. + */ +export function setCaptureApplicationData(enabled: boolean): Promise; +``` + +### 9.3 Why restart-to-activate + +Two reasons: + +1. **Snapshot-at-boot semantics.** The flag's value is read + once, at process start, and embedded in the `init` frame + to the backend (`captureApplicationData: bool`). The + backend wires its `onRequestHook`, OTel sampler, and + custom span emitters based on that snapshot. Hot-toggling + would mean re-registering hooks on a live RPC server, + which adds a class of bugs (in-flight requests with one + instrumentation, new requests with another) for marginal + value. +2. **Predictable user expectation.** The user toggling + "capture more data for debugging" should reasonably + expect a clear before/after, not a partial transition + in the middle of an active sync session. + +A minor cost: if the user has an active issue right now, they +need to flip the toggle and restart the app to start +collecting. The host-app UI says exactly that. + +### 9.4 What the toggle gates + +When `captureApplicationData == true`, the following turn on +in addition to the essential set: + +- **Per-RPC client + server spans.** `tracesSampleRate` + effectively goes from 0 → its configured value (default + 0.1). Method names only; never args. Span attributes + include `rpc.method`, `rpc.status`, `rpc.duration_ms`. +- **Sync session lifecycle transaction.** A + `comapeo.sync.session` transaction from `connectPeers` + (or first peer-connected event) through to + `syncFinished`/`disconnect`. Spans inside for + `discover`, `handshake`, `replicate`. Counts only: + number of peers (bucketed), bytes transferred (bucketed), + duration. **No peer identities, no project IDs in raw + form.** +- **Background/foreground transitions** — host-app `pause` + and `resume` events become `comapeo.app.background` / + `comapeo.app.foreground` breadcrumbs that ride on + subsequent events, helping correlate timing + ("error fired 3s after app backgrounded"). +- **Backend memory checkpoint.** Once at `STARTED` and + every 60s thereafter, a custom context entry on the + next event with `process.memoryUsage()` snapshot + (rss, heapTotal, heapUsed). No event capture by + itself — context only. +- **`privateStorageDir` size sample.** Once at `STARTED`, + the on-disk size of dbFolder + indexFolder + customMaps + as a numeric `du`-style integer. Bucketed (`<10MB`, + `10–100MB`, `100MB–1GB`, `>1GB`) before sending to + avoid leaking the exact size of a sensitive dataset. + +### 9.5 Plumbing path + +``` +[user toggles in app settings] + │ + ▼ +setCaptureApplicationData(true) ─── JS ─── + │ + ▼ +ComapeoCoreModule.setCaptureApplicationData ─── Native bridge ─── + │ + ▼ +EncryptedSharedPreferences write (Android) ─── Persisted ─── +UserDefaults.set (iOS) + │ + ▼ +[user is told: restart required] + +============= NEXT LAUNCH ============= + +NodeJSService starts ─── Native ─── + │ + ▼ +read EncryptedSharedPreferences / UserDefaults + │ + ▼ +sentryConfig.captureApplicationData = true + │ + ▼ +embed in init frame to backend ─── Control socket ─── + │ + ▼ +backend initSentry({captureApplicationData}) ─── Node ─── + │ + ▼ +- onRequestHook registered (per-RPC spans) +- sync-session emitter registered +- memory-snapshot timer started +- tracesSampleRate raised to configured value +``` + +### 9.6 What the toggle never unlocks + +The §7.4.9 never-capture list applies regardless. Specifically: + +- The toggle does not raise `rpcArgsBytes` from 0; raw RPC + args remain off. (`rpcArgsBytes` is a separate **build-time** + config-plugin option for developer debug builds.) +- The toggle does not start capturing observation contents. +- The toggle does not start capturing precise location. +- The toggle does not start capturing peer identities. + +If a future requirement wants any of those, it lands as a +*separate*, more-restrictive opt-in (and likely never ships +to production at all). + +### 9.7 Default and migration + +Default value when the preference has never been written: +`false`. We never auto-enable. A user upgrading the app to a +version that introduces this toggle starts at `false` and only +enters extended capture when they explicitly flip the switch. + +--- + +## 10. Phasing -### 9.1 Phase 1 — error capture only (smallest delivery) +### 10.1 Phase 1 — JS-side error capture (smallest delivery) -- `configureSentry({ sentry, backend: null })` valid: only - JS-side errors via `state` listeners (§6.3). No backend - changes, no IPC changes, no bundle delta. +- `configureSentry({ sentry })` adapter handoff (§4.3). +- `state` listeners capture ERROR transitions and + `messageerror` events via `@sentry/react-native` (§6.3). - Ship as `@comapeo/core-react-native/sentry` sub-export. -- Host app (CoMapeo Mobile) calls it after `Sentry.init`. +- Host app (CoMapeo Mobile) calls `Sentry.init` itself. Value: immediate visibility into rootkey failures, watchdog timeouts, IPC errors, and `messageerror` parse failures — -the most actionable production failures we have today. +provided RN is alive when they fire. (The FGS-cold-start gap +is closed in Phase 2.) Cost: ~50 LOC in `src/sentry.ts`, no native or backend changes, zero risk to other consumers. -### 9.2 Phase 2 — backend error capture + RPC tracing +### 10.2 Phase 2 — Expo config plugin + native config consumption + +- New `app.plugin.js` at module root (§4.1). +- iOS reads Info.plist into `SentryConfig` at app delegate + init; Android reads manifest meta-data at FGS `onCreate`. +- Native error tagging (§7.4.7) and FGS-side + `SentryAndroid.init` from manifest values. +- State-transition breadcrumbs and boot transaction + (§7.4.1, §7.4.2) wired into the existing + `NodeJSService` state-derivation callsites. +- Timeout events (§7.4.4) on the existing watchdog firing + paths. + +Value: native-side error capture is live for production users +without depending on RN being alive. FGS cold-start path is +fully observable. Boot durations dashboarded. + +Cost: ~150 LOC native (Kotlin + Swift), ~50 LOC plugin, no +backend changes yet. + +### 10.3 Phase 3 — backend error capture + RPC tracing - Add `@sentry/node` to `backend/package.json`, bundle it. -- Extend `init` frame with optional `sentry` field. +- Extend `init` frame with optional `sentry` field (§4.5). - `handleFatal` and `onRequestHook` wired (§5.3, §5.4). -- iOS/Android `setSentryConfig` bridge methods (§7.1). - Client-side `getMetadata` (§6.2) for distributed tracing (or accept JS-side spans without parent linkage if `@comapeo/ipc` doesn't yet support it — track upstream). @@ -774,15 +1305,7 @@ distributed tracing. Cost: ~200 LOC across backend, JS, and native; ~150–250 KB bundle delta on every consumer (mitigations in §5.1). -### 9.3 Phase 3 — Android FGS-process Sentry - -- `installSentryInFgs(application, options)` helper (§7.2). -- Document the multi-process init pattern in README. - -Value: FGS-process hard crashes and FGS-local errors get -process-tagged Sentry events with FGS-context breadcrumbs. - -### 9.4 Phase 4 — `@comapeo/core` OpenTelemetry forwarding +### 10.4 Phase 4 — `@comapeo/core` OpenTelemetry forwarding - Bump `@comapeo/core` once PR #1051 lands. - Verify Sentry's OTel integration picks up the spans @@ -793,19 +1316,34 @@ Value: deep traces inside core operations (sync, indexing, hypercore) — the data Sentry's performance tab is designed to surface. -### 9.5 Phase 5 — refinements +### 10.5 Phase 5 — capture-application-data toggle + +- Native preference store (Android `EncryptedSharedPreferences`, + iOS `UserDefaults`) with `getCaptureApplicationData` / + `setCaptureApplicationData` JS API (§9.2). +- Read on boot, embed in `init` frame, gates the §7.4.8 opt-in + captures (per-RPC method spans, sync session transaction, + background/foreground breadcrumbs, memory checkpoints, + storage size sample). +- `before_send` privacy processor (§7.4.9 enforcement). + +Value: opt-in detailed observability for users who consent, +useful for performance investigations and usage-pattern +debugging without exposing PII. + +Cost: ~150 LOC native + JS + backend. + +### 10.6 Phase 6 — refinements - Tune sample rates from production data. -- Add structured breadcrumbs for state transitions (so - pre-error context shows the boot sequence). - Optional: dual backend bundles for Sentry-free consumers if bundle size becomes a concern. --- -## 10. Test plan +## 11. Test plan -### 10.1 Unit / integration +### 11.1 Unit / integration - `src/sentry.ts` accepts a fake adapter; assert `captureException` is called for synthetic ERROR @@ -817,27 +1355,59 @@ to surface. `init` and confirm `Sentry.init` is never called. Build with the field and confirm `onRequestHook` is registered (assert via metadata propagation). - -### 10.2 Manual smoke +- Config plugin: snapshot test that running the plugin with + a `sentry` argument writes the expected manifest + meta-data and Info.plist keys. Run without argument → + no entries written. +- Native config store: synthetic manifest / plist with + partial keys decode into `SentryConfig` with `null` for + missing optional fields; total absence returns `null`. +- Native breadcrumb emission: drive `NodeJSService` through a + scripted state-machine sequence and assert the breadcrumbs + posted to a fake Sentry SDK match the expected shape and + level mapping. +- Toggle persistence: write `setCaptureApplicationData(true)`, + read it back, kill the process, read it back again — value + survives. Re-launch and confirm the flag flows into the + init frame. +- `before_send` privacy processor: feed it events containing + base64-shaped strings, latitude/longitude markers, and raw + project IDs; assert each is redacted or dropped. + +### 11.2 Manual smoke - Run the example app with a temporary DSN (a test Sentry - project). Trigger: + project) configured via the plugin. Trigger: - A deliberate JS-side throw inside a `comapeo.*` callback → JS-layer event in Sentry. - A backend throw via a debug RPC method → backend-layer event with parent transaction. - An Android FGS rootkey-store corruption (delete the keystore alias) → ERROR event with `phase:rootkey` - from both FGS-process and main-process scopes. + from both FGS-process and main-process scopes, with + state-transition breadcrumbs in the trail. - A node abort (`process.abort()` via a debug RPC) → `sentry-android` native crash event. + - Force the FGS startup watchdog to fire (e.g. by + blocking `initPromise` in a test build) → timeout + event with `timeout:startup` tag. + - **FGS cold-start path**: from a freshly-killed app + state, trigger an FGS-only launch (background sync + intent) without bringing RN up. Verify boot + transaction lands in Sentry from the FGS process + alone. +- Toggle "capture application data" on, restart, and run + a scripted sync session. Confirm `comapeo.sync.session` + transaction appears with bucketed peer count and no + raw peer identities. Toggle off, restart, and confirm + the transaction stops appearing. - Confirm no PII in events: open each event, scan for base64-shaped 22-char strings, file paths under `Application Support`, project secrets. - Confirm distributed trace shows JS-client span → backend RPC transaction → (with PR #1051) core operation spans. -### 10.3 Regression +### 11.3 Regression - Run the existing `e2e/run-instrumented-tests.sh` and the iOS `swift test` / `xcodebuild test` suite with @@ -847,7 +1417,7 @@ to surface. --- -## 11. Open questions +## 12. Open questions 1. **Does `@comapeo/ipc@^8` support a client-side `getMetadata` hook?** §6.2 hinges on this. If not, what's the upstream @@ -869,10 +1439,35 @@ to surface. Sentry events can carry a `proc:fgs` tag. Confirm the host app's `@sentry/react-native` config doesn't override our tag in the main-process events. +6. **Release tagging via plugin**: §4.1 has the consumer pass + `release` as a literal in `app.json`. CoMapeo Mobile likely + wants this auto-derived from the host app's version. The + plugin can read `config.version` (the consumer's `expo.version`) + as a default; a `${VERSION}` placeholder is another option. + Decide which. +7. **Plugin output for empty config**: when the consumer + registers the plugin without a `sentry` argument (e.g. just + `["@comapeo/core-react-native"]`), the plugin should be a + no-op for Sentry. Confirm we don't accidentally write empty + `` entries that confuse the native loader. +8. **Toggle UI surface**: where does the host app expose the + `setCaptureApplicationData` switch? CoMapeo Mobile already + has a settings screen — coordinate the copy and restart + prompt. Out of scope for this module but called out for + integration. +9. **Boot transaction sample rate**: §7.4.2 forces 100% on boot + even when overall `tracesSampleRate` is low. Confirm this + doesn't blow Sentry quota for high-launch-volume users. + May need a 1-in-N sampler with a minimum floor. +10. **`EncryptedSharedPreferences` for the toggle**: it's + stronger than necessary for a non-sensitive boolean. Plain + `SharedPreferences` would be simpler and faster. Decision + pending unless we want the toggle's value masked from + on-device tooling, which seems unnecessary. --- -## 12. Summary of file changes +## 13. Summary of file changes Concrete touch list, by phase, for code review. @@ -884,30 +1479,66 @@ Concrete touch list, by phase, for code review. with `peerDependenciesMeta.optional: true`. - `docs/sentry-integration-plan.md` (this file). -**Phase 2** +**Phase 2 — Expo plugin + native config + breadcrumbs/spans** + +- `app.plugin.js` (new, module root) — `withAndroidManifest` to + inject `` and `withInfoPlist` to inject keys. +- `expo-module.config.json` — register the plugin if needed + (the file is already wired to expo-modules via this manifest). +- `ios/SentryConfigStore.swift` (new) — read Info.plist into + `SentryConfig`. +- `android/src/main/java/com/comapeo/core/SentryConfigStore.kt` + (new) — read manifest meta-data into `SentryConfig`. +- `ios/AppLifecycleDelegate.swift` — read config and stash on + `NodeJSService` before `runNode()`. +- `ios/NodeJSService.swift` — accept stored config, embed in + init frame. +- `android/src/main/java/com/comapeo/core/ComapeoCoreService.kt` + — read config in `onCreate`, init `SentryAndroid` for the + FGS process, pass to `NodeJSService`. +- `android/src/main/java/com/comapeo/core/NodeJSService.kt`, + `ios/NodeJSService.swift` — add `Sentry.addBreadcrumb` calls + on every state-derivation update; wrap boot phases in + `Sentry.startSpan`; emit timeout events. +- `android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt` + (main process) — same breadcrumb/event emission from the + control-IPC observer. + +**Phase 3 — backend instrumentation** - `backend/package.json` — `@sentry/node` dependency. - `backend/index.js` — `initSentry`, hook `handleFatal`, extend - `init` handler. + `init` handler validation. - `backend/lib/comapeo-rpc.js` — accept `sentry` option, register `onRequestHook`. - `src/ComapeoCoreModule.ts` — pass `getMetadata` to `createMapeoClient` (or wrapper fallback). -- `ios/ComapeoCoreModule.swift`, `ios/NodeJSService.swift` — - `setSentryConfig` and embed in `init` frame. -- `android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt`, - `NodeJSService.kt` — same on Android, plus FGS Intent extra. - -**Phase 3** -- `android/src/main/java/com/comapeo/core/ComapeoCoreInit.kt` - (new) — FGS-side Sentry init helper. -- README — document FGS init pattern for host apps. - -**Phase 4** +**Phase 4 — OpenTelemetry forwarding** - `backend/package.json` — bump `@comapeo/core` once PR #1051 ships. - Smoke test verification, no code changes expected. +**Phase 5 — capture-application-data toggle** + +- `android/src/main/java/com/comapeo/core/SentryPrefsStore.kt` + (new) — `EncryptedSharedPreferences` read/write of the + toggle, plus `getCaptureApplicationData` / + `setCaptureApplicationData` bridge. +- `ios/SentryPrefsStore.swift` (new) — `UserDefaults` + equivalent. +- `android/.../ComapeoCoreModule.kt`, `ios/ComapeoCoreModule.swift` + — Expo bridge `Function` entries for the two methods. +- `src/sentry.ts` — JS exports `getCaptureApplicationData`, + `setCaptureApplicationData`. +- `backend/lib/comapeo-rpc.js` — wire `tracesSampleRate` + conditionally on the toggle; register sync-session emitter + only when on. +- `backend/index.js` — accept `captureApplicationData` in + init payload; gate memory-checkpoint timer and storage + sampling. +- `backend/before-send.js` (new) — `before_send` privacy + processor (the §7.4.9 redaction belt-and-suspenders). + --- From 0892aeaaaafce4e808fd482e183ad1888f09f8c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 10:27:59 +0000 Subject: [PATCH 03/33] feat(bench): UDS / RPC bridge benchmark suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a stripped bench backend (`backend/index.bench.js` + bench RPC server with echo / payload methods) and a sibling `apps/benchmark/` app that drives it through the same RN→native→Node UDS path as production, isolating the framing / IPC / RPC bridge from @comapeo/core init noise. Consumer isolation is enforced three ways: - the bench bundle lands at sibling paths (`android/src/bench/assets/`, `ios/nodejs-project-bench/`) the production flavor / podspec don't reference; - a new Android `bench` productFlavor + iOS `ENV['COMAPEO_BENCH']` podspec toggle is opt-in only; - `package.json` files array negates both bench paths so they cannot leak via `npm publish`. `apps/benchmark/` does not check in `android/` or `ios/` — the new `with-comapeo-bench` Expo config plugin re-applies the variant / env-var / Xcode rename build phase wiring on every `expo prebuild`. Standalone-runnable: NDJSON sink + on-screen p50/p95/p99 work without any host-side infrastructure. Optional HTTP toggle posts spans to the bundled `bench-receiver.ts` for orchestrated BrowserStack runs. Maestro flows (bench-rpc + per-payload-size variants) drive the bench end-to-end. See `docs/uds-rpc-bridge-benchmark-plan.md` for the full design. https://claude.ai/code/session_01SC1Sc9AvULHQkQSoQ2SMzJ --- .gitignore | 6 + android/build.gradle | 37 + apps/benchmark/.gitignore | 40 + apps/benchmark/App.tsx | 519 ++ apps/benchmark/app.json | 27 + apps/benchmark/assets/adaptive-icon.png | Bin 0 -> 17547 bytes apps/benchmark/assets/favicon.png | Bin 0 -> 1466 bytes apps/benchmark/assets/icon.png | Bin 0 -> 22380 bytes apps/benchmark/assets/splash-icon.png | Bin 0 -> 17547 bytes apps/benchmark/babel.config.js | 6 + apps/benchmark/index.ts | 5 + apps/benchmark/metro.config.js | 34 + apps/benchmark/package-lock.json | 7421 +++++++++++++++++ apps/benchmark/package.json | 32 + .../plugins/with-comapeo-bench/index.js | 202 + apps/benchmark/tsconfig.json | 10 + backend/index.bench.js | 192 + backend/lib/bench-rpc.js | 133 + backend/lib/boot-spans.js | 45 + backend/lib/telemetry-sink.js | 161 + backend/rollup.config.ts | 127 +- docs/uds-rpc-bridge-benchmark-plan.md | 322 + e2e/.maestro/bench-payload-1KB.yaml | 28 + e2e/.maestro/bench-payload-1MB.yaml | 35 + e2e/.maestro/bench-payload-64B.yaml | 30 + e2e/.maestro/bench-payload-64KB.yaml | 28 + e2e/.maestro/bench-rpc.yaml | 36 + ios/ComapeoCore.podspec | 20 +- package.json | 4 +- scripts/build-backend.ts | 53 + scripts/lib/bench-receiver.ts | 182 + src/ComapeoCoreModule.ts | 21 +- src/index.ts | 2 +- 33 files changed, 9737 insertions(+), 21 deletions(-) create mode 100644 apps/benchmark/.gitignore create mode 100644 apps/benchmark/App.tsx create mode 100644 apps/benchmark/app.json create mode 100644 apps/benchmark/assets/adaptive-icon.png create mode 100644 apps/benchmark/assets/favicon.png create mode 100644 apps/benchmark/assets/icon.png create mode 100644 apps/benchmark/assets/splash-icon.png create mode 100644 apps/benchmark/babel.config.js create mode 100644 apps/benchmark/index.ts create mode 100644 apps/benchmark/metro.config.js create mode 100644 apps/benchmark/package-lock.json create mode 100644 apps/benchmark/package.json create mode 100644 apps/benchmark/plugins/with-comapeo-bench/index.js create mode 100644 apps/benchmark/tsconfig.json create mode 100644 backend/index.bench.js create mode 100644 backend/lib/bench-rpc.js create mode 100644 backend/lib/boot-spans.js create mode 100644 backend/lib/telemetry-sink.js create mode 100644 docs/uds-rpc-bridge-benchmark-plan.md create mode 100644 e2e/.maestro/bench-payload-1KB.yaml create mode 100644 e2e/.maestro/bench-payload-1MB.yaml create mode 100644 e2e/.maestro/bench-payload-64B.yaml create mode 100644 e2e/.maestro/bench-payload-64KB.yaml create mode 100644 e2e/.maestro/bench-rpc.yaml create mode 100644 scripts/lib/bench-receiver.ts diff --git a/.gitignore b/.gitignore index cdfe0ae..be51a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -69,12 +69,18 @@ ios/.swiftpm/ # ABI into jniLibs/. # * iOS: rolled-up JS bundle into ios/nodejs-project/ + per-addon # xcframework into ios/Frameworks/. +# * Bench-only sibling outputs (npm run backend:build -- --bench) +# land in `android/src/bench/assets/` and `ios/nodejs-project-bench/`; +# activated by the `bench` Android productFlavor and the +# `ENV['COMAPEO_BENCH']` iOS env var respectively. backend/dist nodejs-assets android/src/debug/assets/ android/src/main/assets/ android/src/main/jniLibs/ +android/src/bench/assets/ ios/nodejs-project/ +ios/nodejs-project-bench/ ios/Frameworks/ # output diff --git a/android/build.gradle b/android/build.gradle index 5a3a115..bde32ec 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -87,6 +87,31 @@ android { abiFilters(*comapeoAbiFilters) } } + + // Flavor `bench` opts the consuming app into the benchmark JS + // bundle (`backend/index.bench.js`, rolled up into + // `src/bench/assets/nodejs-project/`). Default consumers + // (`apps/example/`, third-party apps) compile the `production` + // flavor and never see `src/bench/`. The bench app + // (`apps/benchmark/`) activates `bench` via + // `missingDimensionStrategy 'comapeo', 'bench'` injected by its + // `with-comapeo-bench` Expo config plugin. + // + // Flavors are intentionally orthogonal to build types: bench works + // in both debug and release so real-app perf-feel can be measured + // under R8/ProGuard. The bench sourceSet only adds assets — no + // code, no native libs — so minification has nothing to strip. + flavorDimensions "comapeo" + productFlavors { + production { + dimension "comapeo" + isDefault true + } + bench { + dimension "comapeo" + } + } + lintOptions { abortOnError false } @@ -102,6 +127,18 @@ android { srcDirs 'src/main/assets' } } + // When the `bench` flavor is selected, AGP merges this sourceSet + // on top of `main`. `index.mjs` (the rolled-up bench bundle) + // overlays its production counterpart at the same relative path + // (`nodejs-project/index.mjs`); the rest of `nodejs-project/` + // (drizzle migrations etc.) is inherited from `main` but never + // imported by `index.bench.js` — bench-bundle bloat without + // correctness impact. + bench { + assets { + srcDirs 'src/bench/assets' + } + } } externalNativeBuild { cmake { diff --git a/apps/benchmark/.gitignore b/apps/benchmark/.gitignore new file mode 100644 index 0000000..f8ca347 --- /dev/null +++ b/apps/benchmark/.gitignore @@ -0,0 +1,40 @@ +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native (regenerated by `expo prebuild`; the with-comapeo-bench config +# plugin re-applies bench wiring on every prebuild, so checking these +# in would only invite drift) +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# expo prebuild +/android +/ios diff --git a/apps/benchmark/App.tsx b/apps/benchmark/App.tsx new file mode 100644 index 0000000..79c58e7 --- /dev/null +++ b/apps/benchmark/App.tsx @@ -0,0 +1,519 @@ +import { + benchMessagePort, + state, + type ComapeoState, +} from "@comapeo/core-react-native"; +import { Directory, File, Paths } from "expo-file-system"; +import * as Sharing from "expo-sharing"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +/** + * Benchmark app entry. Drives the bench RPC bridge through the same + * RN→native→Node UDS path as the production module, but talks to the + * stripped `backend/index.bench.js` (via the `bench` Android + * productFlavor / `ENV['COMAPEO_BENCH']` iOS opt-in) — so timings + * isolate the framing / IPC / JSON-RPC bridge from `@comapeo/core` init + * noise. See `docs/uds-rpc-bridge-benchmark-plan.md`. + * + * UI surface: + * - boot status (state observer): waits for "READY" before enabling + * the run button. + * - payload-size selector: subset of {64B, 1KB, 64KB, 1MB} per run. + * - "Run benchmark" button (testID="send-button"): runs warmup + + * steady-state sweep, records per-RPC RTT. + * - results panel (testID="benchmark-result"): per-size p50/p95/p99 + * over the steady-state samples. + * - "Export results" button: writes NDJSON to the app's documents + * directory and opens the system share sheet. + * - optional "POST to receiver" toggle + URL: forwards each span as + * JSON to a host-side `bench-receiver.ts` for orchestrated runs. + * Defaults to off; failures are silently swallowed so the + * on-device experience is unaffected. + */ + +const PAYLOAD_SIZES = [64, 1024, 65536, 1048576] as const; +const DEFAULT_SELECTED: ReadonlyArray = [64, 1024, 65536]; +const WARMUP_ITERATIONS = 10; +const STEADY_ITERATIONS = 100; +const RECEIVER_DEFAULT_URL = "http://localhost:8787/spans"; + +type BenchSpan = { + op: "rpc"; + name: string; + startTimestamp: number; + durationMs: number; + attrs: { bytes: number; rttSide: "rn" }; +}; + +type SizeStats = { + sizeBytes: number; + count: number; + p50: number; + p95: number; + p99: number; + min: number; + max: number; +}; + +type RunReport = { + runId: string; + startedAt: string; + device: { os: string; arch?: string }; + stats: SizeStats[]; + spanFile: string; +}; + +class BenchClient { + private nextId = 0; + private pending = new Map void>(); + private listenerInstalled = false; + + ensureListener() { + if (this.listenerInstalled) return; + this.listenerInstalled = true; + benchMessagePort.addListener("message", (msg) => { + if ( + !msg || + typeof msg !== "object" || + typeof (msg as Record).id !== "string" + ) { + return; + } + const m = msg as { id: string; result?: unknown; error?: { message: string } }; + const cb = this.pending.get(m.id); + if (cb) { + this.pending.delete(m.id); + cb({ result: m.result, error: m.error }); + } + }); + } + + request(method: string, params?: unknown): Promise<{ result?: unknown; error?: { message: string } }> { + this.ensureListener(); + const id = `bench-${this.nextId++}`; + return new Promise((resolve) => { + this.pending.set(id, resolve); + benchMessagePort.postMessage({ id, method, params } as never); + }); + } +} + +function percentile(sortedAsc: number[], p: number): number { + if (sortedAsc.length === 0) return Number.NaN; + // Linear interpolation between closest ranks. For our sample sizes + // (~100), `Math.floor((n-1) * p)` is good enough and avoids the + // off-by-one trap of `Math.floor(n * p)` (which would index past the + // end at p=1). + const idx = Math.floor((sortedAsc.length - 1) * p); + return sortedAsc[idx]!; +} + +function summarise(samples: number[], sizeBytes: number): SizeStats { + const sorted = [...samples].sort((a, b) => a - b); + return { + sizeBytes, + count: sorted.length, + min: sorted[0] ?? Number.NaN, + max: sorted[sorted.length - 1] ?? Number.NaN, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + p99: percentile(sorted, 0.99), + }; +} + +function formatBytes(n: number): string { + if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(0)}MB`; + if (n >= 1024) return `${(n / 1024).toFixed(0)}KB`; + return `${n}B`; +} + +export default function App() { + const [serviceState, setServiceState] = useState(state.getState()); + const [selected, setSelected] = useState>(DEFAULT_SELECTED); + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(""); + const [report, setReport] = useState(null); + const [postEnabled, setPostEnabled] = useState(false); + const [receiverUrl, setReceiverUrl] = useState(RECEIVER_DEFAULT_URL); + + const clientRef = useRef(null); + if (!clientRef.current) clientRef.current = new BenchClient(); + + useEffect(() => { + const onChange = (next: ComapeoState) => setServiceState(next); + state.addListener("stateChange", onChange); + return () => { + state.removeListener("stateChange", onChange); + }; + }, []); + + const toggleSize = useCallback((size: number) => { + setSelected((prev) => + prev.includes(size) ? prev.filter((s) => s !== size) : [...prev, size].sort((a, b) => a - b), + ); + }, []); + + const runBench = useCallback(async () => { + if (running) return; + if (serviceState !== "STARTED") return; + if (selected.length === 0) return; + + setRunning(true); + setReport(null); + const client = clientRef.current!; + const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const startedAt = new Date().toISOString(); + const allSpans: BenchSpan[] = []; + const stats: SizeStats[] = []; + + try { + for (const sizeBytes of selected) { + setProgress(`warmup ${formatBytes(sizeBytes)}…`); + for (let i = 0; i < WARMUP_ITERATIONS; i++) { + // Discard timing, just prime caches. + await client.request("payload", { sizeBytes }); + } + + setProgress(`measuring ${formatBytes(sizeBytes)}…`); + const samples: number[] = []; + for (let i = 0; i < STEADY_ITERATIONS; i++) { + const start = global.performance.now(); + const startMs = Date.now(); + const { error } = await client.request("payload", { sizeBytes }); + const durationMs = global.performance.now() - start; + if (error) { + console.warn(`bench: rpc.payload error at size ${sizeBytes}:`, error.message); + continue; + } + samples.push(durationMs); + const span: BenchSpan = { + op: "rpc", + name: "rpc.payload", + startTimestamp: startMs, + durationMs, + attrs: { bytes: sizeBytes, rttSide: "rn" }, + }; + allSpans.push(span); + if (postEnabled) { + // Fire-and-forget — failures are intentionally silent so a + // missing receiver doesn't break the on-device flow. + fetch(receiverUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...span, runId }), + }).catch(() => {}); + } + } + stats.push(summarise(samples, sizeBytes)); + } + + // Persist the full NDJSON dump for export. + const dir = new Directory(Paths.document, "comapeo-bench"); + if (!dir.exists) dir.create({ intermediates: true }); + const file = new File(dir, `${runId}.ndjson`); + const ndjson = allSpans.map((s) => JSON.stringify({ ...s, runId })).join("\n") + "\n"; + file.create(); + file.write(ndjson); + + setReport({ + runId, + startedAt, + device: { os: getOs() }, + stats, + spanFile: file.uri, + }); + setProgress(`done — ${allSpans.length} spans`); + } catch (e) { + console.error("bench: run failed", e); + setProgress(`error: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setRunning(false); + } + }, [running, serviceState, selected, postEnabled, receiverUrl]); + + const exportResults = useCallback(async () => { + if (!report) return; + try { + const available = await Sharing.isAvailableAsync(); + if (!available) { + setProgress(`file: ${report.spanFile}`); + return; + } + await Sharing.shareAsync(report.spanFile, { + mimeType: "application/x-ndjson", + dialogTitle: "Export bench results", + }); + } catch (e) { + console.warn("bench: export failed", e); + setProgress(`export error: ${e instanceof Error ? e.message : String(e)}`); + } + }, [report]); + + const canRun = serviceState === "STARTED" && !running && selected.length > 0; + + return ( + + + + UDS / RPC Bridge Benchmark + + + + + {serviceState} + + + + + + {PAYLOAD_SIZES.map((s) => { + const active = selected.includes(s); + return ( + toggleSize(s)} + style={[styles.sizeChip, active && styles.sizeChipActive]} + testID={`size-${s}`} + > + + {formatBytes(s)} + + + ); + })} + + + + + + + + {postEnabled && ( + + )} + + + + + {running ? `Running… ${progress}` : "Run benchmark"} + + + + {report && ( + + + started {report.startedAt} + + + size + n + p50 + p95 + p99 + + {report.stats.map((row) => ( + + {formatBytes(row.sizeBytes)} + {row.count} + {row.p50.toFixed(2)} + {row.p95.toFixed(2)} + {row.p99.toFixed(2)} + + ))} + + (durations in ms, RN-thread RTT) + + Export results (NDJSON) + + + {report.spanFile} + + + + )} + + + ); +} + +function Group(props: { name: string; children: React.ReactNode }) { + return ( + + {props.name} + {props.children} + + ); +} + +function Row(props: { label: string; children: React.ReactNode }) { + return ( + + {props.label} + {props.children} + + ); +} + +function getOs(): string { + // Avoid pulling in `react-native/Platform` types here — the check is + // best-effort metadata, not load-bearing logic. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Platform = require("react-native").Platform as { OS: string }; + return Platform.OS; +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#eee", + }, + container: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + title: { + fontSize: 26, + margin: 20, + fontWeight: "600", + }, + group: { + margin: 12, + backgroundColor: "#fff", + borderRadius: 10, + padding: 16, + }, + groupHeader: { + fontSize: 16, + marginBottom: 10, + fontWeight: "600", + }, + row: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginVertical: 4, + }, + rowLabel: { + color: "#666", + }, + sizeRow: { + flexDirection: "row", + flexWrap: "wrap", + }, + sizeChip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 16, + borderWidth: 1, + borderColor: "#bbb", + marginRight: 8, + marginBottom: 8, + }, + sizeChipActive: { + backgroundColor: "#0070f3", + borderColor: "#0070f3", + }, + sizeChipText: { + color: "#333", + }, + sizeChipTextActive: { + color: "#fff", + fontWeight: "600", + }, + input: { + marginTop: 8, + paddingHorizontal: 10, + paddingVertical: 8, + borderWidth: 1, + borderColor: "#ddd", + borderRadius: 6, + backgroundColor: "#fafafa", + fontSize: 14, + }, + button: { + marginHorizontal: 12, + marginVertical: 8, + backgroundColor: "#0070f3", + paddingVertical: 14, + borderRadius: 8, + alignItems: "center", + }, + buttonDisabled: { + backgroundColor: "#9bb", + }, + buttonText: { + color: "#fff", + fontWeight: "600", + fontSize: 16, + }, + table: { + marginTop: 8, + }, + tableHeaderRow: { + flexDirection: "row", + borderBottomWidth: 1, + borderColor: "#eee", + paddingBottom: 4, + marginBottom: 4, + }, + tableHeader: { + fontWeight: "600", + color: "#666", + }, + tableRow: { + flexDirection: "row", + paddingVertical: 4, + }, + tableCell: { + flex: 1, + fontVariant: ["tabular-nums"], + }, + subtle: { + color: "#888", + fontSize: 12, + marginTop: 6, + }, + exportButton: { + marginTop: 12, + paddingVertical: 10, + borderRadius: 6, + backgroundColor: "#eef4ff", + alignItems: "center", + }, + exportButtonText: { + color: "#0070f3", + fontWeight: "600", + }, +}); diff --git a/apps/benchmark/app.json b/apps/benchmark/app.json new file mode 100644 index 0000000..ab7f865 --- /dev/null +++ b/apps/benchmark/app.json @@ -0,0 +1,27 @@ +{ + "expo": { + "name": "core-react-native-benchmark", + "slug": "core-react-native-benchmark", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.comapeo.core.benchmark" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.comapeo.core.benchmark" + }, + "plugins": ["./plugins/with-comapeo-bench"] + } +} diff --git a/apps/benchmark/assets/adaptive-icon.png b/apps/benchmark/assets/adaptive-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..03d6f6b6c6727954aec1d8206222769afd178d8d GIT binary patch literal 17547 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 literal 0 HcmV?d00001 diff --git a/apps/benchmark/assets/icon.png b/apps/benchmark/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a0b1526fc7b78680fd8d733dbc6113e1af695487 GIT binary patch literal 22380 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 literal 0 HcmV?d00001 diff --git a/apps/benchmark/assets/splash-icon.png b/apps/benchmark/assets/splash-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..03d6f6b6c6727954aec1d8206222769afd178d8d GIT binary patch literal 17547 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 ({ + transform: { + experimentalImportSupport: false, + inlineRequires: true, + }, +}); + +module.exports = config; diff --git a/apps/benchmark/package-lock.json b/apps/benchmark/package-lock.json new file mode 100644 index 0000000..59ccb45 --- /dev/null +++ b/apps/benchmark/package-lock.json @@ -0,0 +1,7421 @@ +{ + "name": "core-react-native-benchmark", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "core-react-native-benchmark", + "version": "1.0.0", + "dependencies": { + "expo": "55.0.17", + "expo-file-system": "19.0.18", + "expo-sharing": "14.0.7", + "react": "19.2.5", + "react-native": "0.83.6", + "react-native-safe-area-context": "5.6.2" + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@types/react": "19.2.14", + "typescript": "5.9.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", + "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.29.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", + "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", + "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", + "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", + "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@expo/cli": { + "version": "55.0.26", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-55.0.26.tgz", + "integrity": "sha512-Ud9gpeGMF5RIL42LXvCw3k3mWK8rf/P2wu+Yrzz9Do1kcFKZeT9Vy2D/xukjdr/Xw+ELba87ThOot17GsPiWjw==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~55.0.15", + "@expo/config-plugins": "~55.0.8", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.1.1", + "@expo/image-utils": "^0.8.13", + "@expo/json-file": "^10.0.13", + "@expo/log-box": "55.0.11", + "@expo/metro": "~55.1.0", + "@expo/metro-config": "~55.0.17", + "@expo/osascript": "^2.4.2", + "@expo/package-manager": "^1.10.4", + "@expo/plist": "^0.5.2", + "@expo/prebuild-config": "^55.0.16", + "@expo/require-utils": "^55.0.4", + "@expo/router-server": "^55.0.15", + "@expo/schema-utils": "^55.0.3", + "@expo/spawn-async": "^1.7.2", + "@expo/ws-tunnel": "^1.0.1", + "@expo/xcpretty": "^4.4.0", + "@react-native/dev-middleware": "0.83.6", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "better-opn": "~3.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.4", + "expo-server": "^55.0.8", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^1.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.3", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "source-map-support": "~0.5.21", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "build/bin/cli" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/cli/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/config": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.15.tgz", + "integrity": "sha512-lHc0ELIQ8126jYOMZpLv3WIuvordW98jFg5aT/J1/12n2ycuXu01XLZkJsdw0avO34cusUYb1It+MvY8JiMduA==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~55.0.8", + "@expo/config-types": "^55.0.5", + "@expo/json-file": "^10.0.13", + "@expo/require-utils": "^55.0.4", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/config-plugins": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.8.tgz", + "integrity": "sha512-8WfWTRntTCcowfOS+tHdB0z98gKetTwktg4G5TWkCkXVa8Jt1NUnvzaaU4UHk2vbR2U4N84RyZJFizSwfF6C9g==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^55.0.5", + "@expo/json-file": "~10.0.13", + "@expo/plist": "^0.5.2", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/config-types": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", + "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", + "license": "MIT" + }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "55.0.2", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-55.0.2.tgz", + "integrity": "sha512-4VsFn9MUriocyuhyA+ycJP3TJhUsOFHDc270l9h3LhNpXMf6wvIdGcA0QzXkZtORXmlDybWXRP2KT1k36HcQkA==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/dom-webview": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-55.0.5.tgz", + "integrity": "sha512-lt3uxYOCk3wmWvtOOvsC35CKGbDAOx5C2EaY8SH1JVSfBzqmF8Cs0Xp1MPxncDPMyxpMiWx5SvvV/iLF1rJU4A==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/env": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.1.tgz", + "integrity": "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/fingerprint": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.16.6.tgz", + "integrity": "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==", + "license": "MIT", + "dependencies": { + "@expo/env": "^2.0.11", + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.13.tgz", + "integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^55.0.4", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/json-file": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.13.tgz", + "integrity": "sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/local-build-cache-provider": { + "version": "55.0.11", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.11.tgz", + "integrity": "sha512-rJ4RTCrkeKaXaido/bVyhl90ZRtVTOEbj59F1PWVjIEIVgjdlfc1J3VD9v7hEsbf/+8Tbr/PgvWhT6Visi5sLQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.15", + "chalk": "^4.1.2" + } + }, + "node_modules/@expo/log-box": { + "version": "55.0.11", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-55.0.11.tgz", + "integrity": "sha512-JQHFLWkskIbJi6cxYMjErx8lQqfFJilDQLKmdTO3m3YkdmN9GE/CrzjOfVlCG0DGEGZJ90br0pGKvGPdXNsHKw==", + "license": "MIT", + "dependencies": { + "@expo/dom-webview": "^55.0.5", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "@expo/dom-webview": "^55.0.5", + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/metro": { + "version": "55.1.1", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-55.1.1.tgz", + "integrity": "sha512-/wfXo5hTuAVpVLG/4hzlmD9NBGJkzkmBEMm/4VICajYRbj7y8OmqqPWbbymzHiBiHB6tI9BnsyXpQM6zVZEECg==", + "license": "MIT", + "dependencies": { + "metro": "0.83.7", + "metro-babel-transformer": "0.83.7", + "metro-cache": "0.83.7", + "metro-cache-key": "0.83.7", + "metro-config": "0.83.7", + "metro-core": "0.83.7", + "metro-file-map": "0.83.7", + "metro-minify-terser": "0.83.7", + "metro-resolver": "0.83.7", + "metro-runtime": "0.83.7", + "metro-source-map": "0.83.7", + "metro-symbolicate": "0.83.7", + "metro-transform-plugins": "0.83.7", + "metro-transform-worker": "0.83.7" + } + }, + "node_modules/@expo/metro-config": { + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-55.0.17.tgz", + "integrity": "sha512-o11VyNoRDXv0T5320D9cH+nSsrR/OMHTjtysKLIfDlidsBswDk1DMApPv9Kw0/gluArCSnbx8JC1G0Yh2Y4P3g==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~55.0.15", + "@expo/env": "~2.1.1", + "@expo/json-file": "~10.0.13", + "@expo/metro": "~55.1.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.32.0", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.3", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@expo/osascript": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz", + "integrity": "sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.4.tgz", + "integrity": "sha512-y9Mr4Kmpk4abAVZrNNPCdzOZr8nLLyi18p1SXr0RCVA8IfzqZX/eY4H+50a0HTmXqIsPZrQdcdb4I3ekMS9GvQ==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^10.0.13", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/plist": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.2.tgz", + "integrity": "sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/prebuild-config": { + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-55.0.16.tgz", + "integrity": "sha512-o4EAVgDGk1lISirtMD8hciO2vyMp7cWlPdfTtjjd5AXSfODVYDIDhygXrfvVQHmJXAztVqPUTKJT+BYOsVkYGQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.15", + "@expo/config-plugins": "~55.0.8", + "@expo/config-types": "^55.0.5", + "@expo/image-utils": "^0.8.13", + "@expo/json-file": "^10.0.13", + "@react-native/normalize-colors": "0.83.6", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/require-utils": { + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.4.tgz", + "integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/router-server": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-55.0.15.tgz", + "integrity": "sha512-6LksYO4Pg13qroL138KfUebt/x/EO07zVhdyT/nTgcxnpn6CS4ecTl3DciSKhxbaH+0BVLdANkxYeGdp43TMwQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@expo/metro-runtime": "^55.0.10", + "expo": "*", + "expo-constants": "^55.0.15", + "expo-font": "^55.0.6", + "expo-router": "*", + "expo-server": "^55.0.8", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/@expo/schema-utils": { + "version": "55.0.3", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-55.0.3.tgz", + "integrity": "sha512-l9KHVjTo6MvoeyvwNr6AjckGJm8NIcqZ3QSAh51cWozXW9v2AUjyCyqYtFtyntLWRZ0x/ByYJishpQo4ZQq45Q==", + "license": "MIT" + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", + "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/ws-tunnel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", + "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", + "license": "MIT" + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.3.tgz", + "integrity": "sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.6.tgz", + "integrity": "sha512-iljb4ue1yWJ3EhySz7EjV6CzSVrI2uNtR8BI2jzP5+QS5E4Cl3fdIJRmVwDEx1pu8uE97PGEusGRHnoaZ9Q3jg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.83.6.tgz", + "integrity": "sha512-qfRXsHGeucT5c6mK+8Q7v4Ly3zmygfVmFlEtkiq7q07W1OTreld6nib4rJ/DBEeNiKBoBTuHjWliYGNuDjLFQA==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.83.6" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.83.6.tgz", + "integrity": "sha512-4/fXFDUvGOObETZq4+SUFkafld6OGgQWut5cQiqVghlhCB5z/p2lVhPgEUr/aTxTzeS3AmN+ztC+GpYPQ7tsTw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.83.6", + "babel-plugin-syntax-hermes-parser": "0.32.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/babel-preset/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.0" + } + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.83.6.tgz", + "integrity": "sha512-doB/Pq6Cf6IjF3wlQXTIiZOnsX9X8mEEk+CdGfyuCwZjWrf7IB8KaZEXXckJmfUcIwvJ9u/a72ZoTTCIoxAc9A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.32.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@react-native/codegen/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@react-native/codegen/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@react-native/codegen/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/@react-native/codegen/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/@react-native/codegen/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.83.6.tgz", + "integrity": "sha512-Mko6mywoHYJmpBnjwAC95vQWaUUh//71knFadH0BrhHDq2m7i/IrpLwcQsPAy8855ucXflBs5zQyGTpNbPBAaw==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.83.6", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.83.6", + "metro-config": "^0.83.6", + "metro-core": "^0.83.6", + "semver": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "*" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.83.6.tgz", + "integrity": "sha512-TyWXEpAjVundrc87fPWg91piOUg75+X9iutcfDe7cO3NrAEYCsl7Z09rKHuiAGkxfG9/rFD13dPsYIixUFkSFA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.83.6.tgz", + "integrity": "sha512-684TJMBCU0l0ZjJWzrnK0HH+ERaM9KLyxyArE1k7BrP+gVl4X9GO0Pi94RoInOxvW/nyV65sOU6Ip1F3ygS0cg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.83.6.tgz", + "integrity": "sha512-22xoddLTelpcVnF385SNH2hdP7X2av5pu7yRl/WnM5jBznbcl0+M9Ce94cj+WVeomsoUF/vlfuB0Ooy+RMlRiA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.83.6", + "@react-native/debugger-shell": "0.83.6", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.83.6.tgz", + "integrity": "sha512-5prXv7WWR1RgZ/kWGZP+mi7/y/IE2ymfOHIZO5Pv14tMOmRAcQSgSYogcRmOiWw5mJs2K0UFeMiQD49ZO9oCug==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.83.6.tgz", + "integrity": "sha512-VSev0LV2i5X0ibduHBSLqKj0YU2F+waCgjl2uvaGHMGCSV1ZRKNFX/vJFqvLwjvdzLbkAZoFT1Rg7k7jDv44UA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.6.tgz", + "integrity": "sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw==", + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.83.6.tgz", + "integrity": "sha512-gNSFXeb4P7qHtauLvl+zESroULIyX6Ltpvau3dhwy/QmfanBv0KUcrIU/7aVXxtWcXgp+54oWJyu2LIrsZ9+LQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.1.tgz", + "integrity": "sha512-HgErPZTghW76Rkq9uqn5ESeiD97FbqpZ1V170T1RG2RDp+7pJVQV2pQJs7y5YzN0/gcT6GM5ci9apRnIwuyPdQ==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.1" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-expo": { + "version": "55.0.19", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-55.0.19.tgz", + "integrity": "sha512-IaxT7xremfrW2HqtG7gWI7TUSJke/V+zDW1whLpmO06ZdKOfB5Qup7oICqBWqfbcBW3h57llWOMAn1cycvbsgQ==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.83.6", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.32.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^55.0.15", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { + "version": "2.10.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", + "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "license": "MIT", + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/better-opn/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", + "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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/dnssd-advertise": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz", + "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.348", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.348.tgz", + "integrity": "sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo": { + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/expo/-/expo-55.0.17.tgz", + "integrity": "sha512-yVF2phiPw5XgOCedC/oQaL3j0XbwzsBLst3JiAF8bi9aFlxLOVvuDEM8BDg3E09XGSLaGCAclY4q5L+sFerXlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "55.0.26", + "@expo/config": "~55.0.15", + "@expo/config-plugins": "~55.0.8", + "@expo/devtools": "55.0.2", + "@expo/fingerprint": "0.16.6", + "@expo/local-build-cache-provider": "55.0.11", + "@expo/log-box": "55.0.11", + "@expo/metro": "~55.1.0", + "@expo/metro-config": "55.0.17", + "@expo/vector-icons": "^15.0.2", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~55.0.18", + "expo-asset": "~55.0.16", + "expo-constants": "~55.0.15", + "expo-file-system": "~55.0.17", + "expo-font": "~55.0.6", + "expo-keep-awake": "~55.0.6", + "expo-modules-autolinking": "55.0.18", + "expo-modules-core": "55.0.23", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-minimum": "^0.1.1" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-native": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-asset": { + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-55.0.16.tgz", + "integrity": "sha512-5IJyfJtYqvKGg04NKGQWiCIoK/fULDL9m15mXPPyfabD1jsToVj2hnWmo1r2SWNNmMwtQxi6jTpcGwVo2nLDxg==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.13", + "expo-constants": "~55.0.15" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.15.tgz", + "integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.1.1" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-file-system": { + "version": "19.0.18", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.18.tgz", + "integrity": "sha512-/IN4H2HHoRFiuPTs0ty8CwoDxT0GVo2tYcO4BxNniSZ4FYjtPiNWPqLvq7RKV++EHllHox/jnV/rkTzwwNERxA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "55.0.6", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-55.0.6.tgz", + "integrity": "sha512-x9czUA3UQWjIwa0ZUEs/eWJNqB4mAue/m4ltESlNPLZhHL0nWWqIfsyHmklTLFH7mVfcHSJvew6k+pR2FE1zVw==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-keep-awake": { + "version": "55.0.7", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-55.0.7.tgz", + "integrity": "sha512-QBWOEu8FkPBGYc0h0rsCkSTMJNBEKgzVsmLuQpO7V79V9sPR052k3Iiu/G8Kzmny2enyHYYed8RY+CUsip/SeQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "55.0.18", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-55.0.18.tgz", + "integrity": "sha512-olGTCWYkwVPj/momcgnF+z8MTzurGNFjopqPztQ4F53UkGPJnOFEuaM2/z4KbZtKbwHqeBv34OA5hxZP8uLdaQ==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^55.0.4", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.1.0", + "commander": "^7.2.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-core": { + "version": "55.0.23", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-55.0.23.tgz", + "integrity": "sha512-IGWT5N9MoV4zgWyrv686bElnKhzhE7E6pSazhaBNh3vgViAah5nnAz2o5h5YoUMR2B+ZTdHumRbGHN6gHLgwPA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": "^0.7.4 || ^0.8.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/expo-server": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.8.tgz", + "integrity": "sha512-AoV5TKuO4biSzrhe/OVLyInfTT0pV9/OOc/g/oVq5vmCjL8SaSYTkES8PLt+67Tm7VqX+Dn0+kSx1nQcjEKaPw==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-sharing": { + "version": "14.0.7", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.7.tgz", + "integrity": "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo/node_modules/expo-file-system": { + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.17.tgz", + "integrity": "sha512-d27K1cagUOt2BwxwPka9KW8Znu5kN1tnairozCzzCRZviZFtWnBxwFuJ3KU6MAbav/9UhSMkp5Ve/oZ+SR0UgQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fetch-nodeshim": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", + "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-compiler": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.14.1.tgz", + "integrity": "sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.1.tgz", + "integrity": "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.1.tgz", + "integrity": "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.1" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", + "license": "MIT", + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/metro": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.7.tgz", + "integrity": "sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.83.7", + "metro-cache": "0.83.7", + "metro-cache-key": "0.83.7", + "metro-config": "0.83.7", + "metro-core": "0.83.7", + "metro-file-map": "0.83.7", + "metro-resolver": "0.83.7", + "metro-runtime": "0.83.7", + "metro-source-map": "0.83.7", + "metro-symbolicate": "0.83.7", + "metro-transform-plugins": "0.83.7", + "metro-transform-worker": "0.83.7", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.7.tgz", + "integrity": "sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.83.7", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro-cache": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.7.tgz", + "integrity": "sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.83.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-cache-key": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.7.tgz", + "integrity": "sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-config": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.7.tgz", + "integrity": "sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.83.7", + "metro-cache": "0.83.7", + "metro-core": "0.83.7", + "metro-runtime": "0.83.7", + "yaml": "^2.6.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-core": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.7.tgz", + "integrity": "sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.83.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-file-map": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.7.tgz", + "integrity": "sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.7.tgz", + "integrity": "sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-resolver": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.7.tgz", + "integrity": "sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-runtime": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.7.tgz", + "integrity": "sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-source-map": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.7.tgz", + "integrity": "sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.83.7", + "nullthrows": "^1.1.1", + "ob1": "0.83.7", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.7.tgz", + "integrity": "sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.83.7", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.7.tgz", + "integrity": "sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.7.tgz", + "integrity": "sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.7", + "metro-babel-transformer": "0.83.7", + "metro-cache": "0.83.7", + "metro-cache-key": "0.83.7", + "metro-minify-terser": "0.83.7", + "metro-source-map": "0.83.7", + "metro-transform-plugins": "0.83.7", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/metro/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multitars": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", + "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.7.tgz", + "integrity": "sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.83.6", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.83.6.tgz", + "integrity": "sha512-H513+8VzviNFXOdPnStRzX9S3/jiJGg++QZ1zd+ROyAvBEKqFqKUPHH0d82y3QyRPct5qKjdOa7J6vNehCvXYA==", + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/assets-registry": "0.83.6", + "@react-native/codegen": "0.83.6", + "@react-native/community-cli-plugin": "0.83.6", + "@react-native/gradle-plugin": "0.83.6", + "@react-native/js-polyfills": "0.83.6", + "@react-native/normalize-colors": "0.83.6", + "@react-native/virtualized-lists": "0.83.6", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-jest": "^29.7.0", + "babel-plugin-syntax-hermes-parser": "0.32.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "glob": "^7.1.1", + "hermes-compiler": "0.14.1", + "invariant": "^2.2.4", + "jest-environment-node": "^29.7.0", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.83.6", + "metro-source-map": "^0.83.6", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.1", + "react": "^19.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", + "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.0" + } + }, + "node_modules/react-native/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/react-native/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/react-native/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT" + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", + "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", + "license": "MIT" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url-minimum": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.1.tgz", + "integrity": "sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "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/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json new file mode 100644 index 0000000..8d28118 --- /dev/null +++ b/apps/benchmark/package.json @@ -0,0 +1,32 @@ +{ + "name": "core-react-native-benchmark", + "version": "1.0.0", + "main": "index.ts", + "scripts": { + "start": "expo start --dev-client", + "prebuild": "expo prebuild --no-install", + "android": "expo run:android", + "android:release": "expo run:android --variant release --no-install", + "ios": "expo run:ios", + "ios:release": "expo run:ios --configuration Release" + }, + "dependencies": { + "expo": "55.0.17", + "expo-file-system": "19.0.18", + "expo-sharing": "14.0.7", + "react": "19.2.5", + "react-native": "0.83.6", + "react-native-safe-area-context": "5.6.2" + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@types/react": "19.2.14", + "typescript": "5.9.3" + }, + "private": true, + "expo": { + "autolinking": { + "nativeModulesDir": "../.." + } + } +} diff --git a/apps/benchmark/plugins/with-comapeo-bench/index.js b/apps/benchmark/plugins/with-comapeo-bench/index.js new file mode 100644 index 0000000..f116e68 --- /dev/null +++ b/apps/benchmark/plugins/with-comapeo-bench/index.js @@ -0,0 +1,202 @@ +const { + withAppBuildGradle, + withPodfile, + withXcodeProject, +} = require('@expo/config-plugins'); +const { + mergeContents, +} = require('@expo/config-plugins/build/utils/generateCode'); + +/** + * Activates the `bench` Android productFlavor + the iOS bench resource + * opt-in for this Expo app on every `expo prebuild`. The bench app + * (`apps/benchmark/`) does not check in `android/` or `ios/`, so this + * plugin is the single source of truth for the native wiring; production + * consumers (`apps/example/`, third parties) never apply it and never + * see any bench artefacts in their APK / IPA. + * + * Three idempotent mutations: + * + * - `withAppBuildGradle` injects, into `android/app/build.gradle`: + * defaultConfig { missingDimensionStrategy 'comapeo', 'bench' } + * buildTypes.{debug,release} { matchingFallbacks = ['production'] } + * so AGP resolves the consuming app's build types against the + * module's `bench` flavor, and falls back to `production` for + * anything that doesn't have a bench-specific configuration. Heads + * off the known release-variant footgun (expo/expo#18315, #16686, + * #23266). + * + * - `withPodfile` prepends `ENV['COMAPEO_BENCH'] = '1'` to the top of + * `ios/Podfile` so it's set BEFORE Expo autolinking evaluates + * `ComapeoCore.podspec`. The podspec reads that env var and swaps + * `nodejs-project-bench` in for `nodejs-project` in `s.resources`. + * + * - `withXcodeProject` adds a Run Script build phase to the iOS app + * target that renames the embedded `nodejs-project-bench/` directory + * to `nodejs-project/` after Copy Bundle Resources. The native + * loader (`NodeJSService.swift`) reads from a fixed + * `.app/nodejs-project/` path; this rename is what lets the + * bench bundle masquerade as the production bundle on disk + * without touching the loader. + * + * Each mutation guards on a sentinel-tag include-check so re-runs of + * `expo prebuild` are idempotent — string-based mods compose poorly + * without this and the Expo docs explicitly warn about it + * (https://docs.expo.dev/config-plugins/mods/). + */ +function withComapeoBench(config) { + config = withBenchAndroidGradle(config); + config = withBenchPodfile(config); + config = withBenchIosRenameScript(config); + return config; +} + +const ANDROID_DEFAULT_CONFIG_INSERT = + " missingDimensionStrategy 'comapeo', 'bench'"; + +const ANDROID_BUILD_TYPES_INSERT = [ + ' debug {', + " matchingFallbacks = ['production']", + ' }', + ' release {', + " matchingFallbacks = ['production']", + ' }', +].join('\n'); + +function withBenchAndroidGradle(config) { + return withAppBuildGradle(config, (cfg) => { + if (cfg.modResults.language !== 'groovy') { + console.warn( + 'with-comapeo-bench: app/build.gradle is not groovy; skipping Android wiring', + ); + return cfg; + } + let contents = cfg.modResults.contents; + + contents = mergeContents({ + tag: 'with-comapeo-bench:missing-dimension', + src: contents, + newSrc: ANDROID_DEFAULT_CONFIG_INSERT, + anchor: /defaultConfig\s*\{/, + offset: 1, + comment: '//', + }).contents; + + contents = mergeContents({ + tag: 'with-comapeo-bench:matching-fallbacks', + src: contents, + newSrc: ANDROID_BUILD_TYPES_INSERT, + anchor: /buildTypes\s*\{/, + offset: 1, + comment: '//', + }).contents; + + cfg.modResults.contents = contents; + return cfg; + }); +} + +const PODFILE_ENV_LINE = "ENV['COMAPEO_BENCH'] = '1'"; + +function withBenchPodfile(config) { + return withPodfile(config, (cfg) => { + let contents = cfg.modResults.contents; + + if (contents.includes(PODFILE_ENV_LINE)) { + // Already applied by a prior prebuild run. + return cfg; + } + + // Prepend at the top so it runs before any autolinking-block code + // that evaluates `ComapeoCore.podspec` (which reads the env var). + cfg.modResults.contents = + `# @generated by with-comapeo-bench (apps/benchmark/plugins) — bench-only opt-in\n` + + `${PODFILE_ENV_LINE}\n\n` + + contents; + return cfg; + }); +} + +const RENAME_SCRIPT_NAME = 'with-comapeo-bench: rename nodejs-project-bench → nodejs-project'; +const RENAME_SCRIPT_BODY = [ + '#!/bin/sh', + '# @generated by with-comapeo-bench (apps/benchmark/plugins).', + '# CocoaPods copies the bench bundle to .app/nodejs-project-bench/.', + '# The native loader reads from .app/nodejs-project/ (a path that', + '# does not change between flavors — see ios/ComapeoCore.podspec). Move', + '# the bench bundle into place after Copy Bundle Resources so the', + '# loader picks up bench code without changing the lookup path.', + 'set -e', + 'TARGET_DIR="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"', + 'if [ -d "${TARGET_DIR}/nodejs-project-bench" ]; then', + ' rm -rf "${TARGET_DIR}/nodejs-project"', + ' mv "${TARGET_DIR}/nodejs-project-bench" "${TARGET_DIR}/nodejs-project"', + 'fi', +].join('\n'); + +function withBenchIosRenameScript(config) { + return withXcodeProject(config, (cfg) => { + const project = cfg.modResults; + const targets = project.pbxNativeTargetSection(); + if (!targets) return cfg; + + let appTargetUuid; + for (const [uuid, target] of Object.entries(targets)) { + // Skip pbxproj comment entries (uuid keys ending with `_comment`). + if (uuid.includes('_comment')) continue; + // Application target's `productType` is com.apple.product-type.application. + const productType = target.productType?.replace(/['"]/g, ''); + if (productType === 'com.apple.product-type.application') { + appTargetUuid = uuid; + break; + } + } + + if (!appTargetUuid) { + console.warn( + 'with-comapeo-bench: no application target found in pbxproj; skipping rename script', + ); + return cfg; + } + + // Idempotency: if a Run Script phase with our exact name already + // exists anywhere in the pbxproj, leave it alone. (xcode-node + // exposes script phases as a flat dict keyed by UUID; per-target + // filtering would require walking `target.buildPhases` references, + // but a name match is sufficient because the name is plugin- + // generated and unique.) + const allScriptPhases = project.hash?.project?.objects?.PBXShellScriptBuildPhase; + if (allScriptPhases && typeof allScriptPhases === 'object') { + for (const [key, phase] of Object.entries(allScriptPhases)) { + if (key.endsWith('_comment')) continue; + if ( + phase && + typeof phase === 'object' && + phase.name && + String(phase.name).replace(/^"|"$/g, '') === RENAME_SCRIPT_NAME + ) { + return cfg; + } + } + } + + project.addBuildPhase( + [], + 'PBXShellScriptBuildPhase', + RENAME_SCRIPT_NAME, + appTargetUuid, + { + shellPath: '/bin/sh', + shellScript: RENAME_SCRIPT_BODY, + // Run after Copy Bundle Resources so the bench dir is on disk + // when the rename runs. + inputPaths: [], + outputPaths: [], + }, + ); + + return cfg; + }); +} + +module.exports = withComapeoBench; diff --git a/apps/benchmark/tsconfig.json b/apps/benchmark/tsconfig.json new file mode 100644 index 0000000..ddf41e6 --- /dev/null +++ b/apps/benchmark/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@comapeo/core-react-native": ["../../src/index"], + "@comapeo/core-react-native/*": ["../../src/*"] + } + } +} diff --git a/backend/index.bench.js b/backend/index.bench.js new file mode 100644 index 0000000..1e6acbc --- /dev/null +++ b/backend/index.bench.js @@ -0,0 +1,192 @@ +import { BenchRpcServer } from "./lib/bench-rpc.js"; +import { startBootSpan } from "./lib/boot-spans.js"; +import { SimpleRpcServer } from "./lib/simple-rpc.js"; +import { createSinkFromArg } from "./lib/telemetry-sink.js"; + +/** + * Bench-only nodejs-mobile entry. Identical state-machine shape to the + * production `backend/index.js` (so the native loader is unchanged: same + * positional args, same `started` / `ready` broadcasts on the control + * socket, same `stopping` / `error` lifecycle frames) but with two + * substitutions that isolate the bridge under test from `@comapeo/core`: + * + * 1. Init validation is relaxed. The production handler enforces a + * strict-base64 16-byte rootKey; here we accept any non-empty + * `init` frame so the bench app can use a fixed dummy rootKey + * without re-implementing the encoding rules. + * 2. The comapeo-RPC socket runs `BenchRpcServer` (echo + payload + * methods) instead of `ComapeoRpcServer` (the full MapeoManager + * surface). `@comapeo/core` is therefore not imported and the + * rolled-up bundle excludes its drizzle migrations, sqlite addon, + * undici, etc. — exactly the noise we want to drop. + * + * Boot phases (`listen-control`, `init`, `construct`) are wrapped with + * `startBootSpan` so the same Sentry-shaped taxonomy from §7.4.2 of the + * Sentry plan emits to whichever sink the host configured. The three + * native-side phases (`ipc-connect (control)`, `rootkey-load`, + * `ipc-connect (comapeo)`) stay native-side; they're added by the + * Sentry plan when production loaders adopt shared instrumentation. + */ + +console.log("Starting Comapeo Node BENCHMARK server..."); + +// privateStorageDir (3rd positional arg) is consumed by the production +// backend for sqlite + file resources; the bench backend doesn't touch +// disk at the application layer, so it's deliberately unread here. +const [comapeoSocketPath, controlSocketPath, , ...rest] = + process.argv.slice(2); + +// `--telemetry=` selects the sink. See `createSinkFromArg` for +// supported forms. Unspecified → NoopSink, which is the right default +// for a "production-like" run where we want zero tracing overhead. +const telemetryArg = rest.find((a) => a.startsWith("--telemetry=")); +const sink = createSinkFromArg( + telemetryArg ? telemetryArg.slice("--telemetry=".length) : undefined, +); + +/** @type {BenchRpcServer | undefined} */ +let benchRpcServer; + +/** @type {(rootKey: unknown) => void} */ +let resolveInit; +/** @type {(reason: Error) => void} */ +let rejectInit; +/** @type {Promise} */ +const initPromise = new Promise((resolve, reject) => { + resolveInit = resolve; + rejectInit = reject; +}); +let initConsumed = false; + +const controlIpcServer = new SimpleRpcServer({ + /** + * Bench init: relaxed shape check. Accepts any object payload — + * native passes a base64 rootKey string just like in production but + * the bench backend doesn't construct a MapeoManager so the bytes + * are never used. Rejecting on missing payload still surfaces a + * malformed native loader. + * + * @param {Record} message + */ + init: (message) => { + if (initConsumed) { + console.warn("Bench: received init after manager construction; ignoring"); + return; + } + initConsumed = true; + if (!message || typeof message !== "object") { + rejectInit(new Error("Bench init: malformed message")); + return; + } + resolveInit(message.rootKey ?? null); + }, + shutdown: async () => { + // Match production's shutdown frame ordering so native lifecycle + // detection (graceful exit vs. crash) keeps working unchanged. + controlIpcServer.broadcast({ type: "stopping" }); + /** @type {Promise[]} */ + const closePromises = [controlIpcServer.close()]; + if (benchRpcServer) closePromises.push(benchRpcServer.close()); + await Promise.all(closePromises); + await sink.close(); + }, + /** @param {Record} message */ + "error-native": (message) => { + if ( + typeof message.phase !== "string" || + typeof message.message !== "string" + ) { + console.warn("Bench: malformed error-native frame, ignoring", message); + return; + } + handleFatal(message.phase, new Error(message.message)); + }, +}); + +/** + * @param {string} phase + * @param {unknown} error + */ +async function handleFatal(phase, error) { + const err = error instanceof Error ? error : new Error(String(error)); + console.error(`Bench: fatal during ${phase}:`, err); + try { + controlIpcServer.broadcastError({ + phase, + message: err.message, + stack: err.stack, + }); + } catch (broadcastErr) { + console.error("Bench: failed to broadcast error frame", broadcastErr); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + process.exit(1); +} + +process.on("uncaughtException", (error) => { + handleFatal("runtime", error); +}); +process.on("unhandledRejection", (reason) => { + handleFatal("runtime", reason); +}); + +(async () => { + try { + // 1. Bind the control socket. Native is already polling for it. + const listenSpan = startBootSpan(sink, "listen-control"); + try { + await controlIpcServer.listen(controlSocketPath); + } catch (e) { + throw Object.assign( + e instanceof Error ? e : new Error(String(e)), + { phase: "listen-control" }, + ); + } finally { + listenSpan.end(); + } + console.log(`Bench control socket listening on ${controlSocketPath}`); + controlIpcServer.setReadinessPhase("started"); + + // 2. Wait for native to send the `init` frame. In the bench backend + // this is just a synchronisation barrier — the rootKey is not + // consumed. + const initSpan = startBootSpan(sink, "init"); + try { + await initPromise; + } catch (e) { + throw Object.assign( + e instanceof Error ? e : new Error(String(e)), + { phase: "init" }, + ); + } finally { + initSpan.end(); + } + + // 3. Build the bench RPC server and bind the comapeo socket. + const constructSpan = startBootSpan(sink, "construct"); + try { + benchRpcServer = new BenchRpcServer({ sink }); + await benchRpcServer.listen(comapeoSocketPath); + } catch (e) { + throw Object.assign( + e instanceof Error ? e : new Error(String(e)), + { phase: "construct" }, + ); + } finally { + constructSpan.end(); + } + console.log(`Bench comapeo socket listening on ${comapeoSocketPath}`); + + controlIpcServer.setReadinessPhase("ready"); + } catch (error) { + const phase = + error && typeof error === "object" && "phase" in error + ? /** @type {{ phase: string }} */ (error).phase + : "boot"; + handleFatal(phase, error); + } +})(); + +process.on("exit", () => { + console.log("Bench node exiting"); +}); diff --git a/backend/lib/bench-rpc.js b/backend/lib/bench-rpc.js new file mode 100644 index 0000000..5e23a75 --- /dev/null +++ b/backend/lib/bench-rpc.js @@ -0,0 +1,133 @@ +import { ServerHelper } from "./server-helper.js"; +import { SocketMessagePort } from "./message-port.js"; +import { startSpan } from "./telemetry-sink.js"; + +/** + * Pre-allocated payload buffers, indexed by size class. A new bench run + * with mixed sizes would otherwise spend its time in `String.repeat` + * rather than measuring the bridge — this caches `"x".repeat(N)` once + * per size encountered. Capped at 4 MiB to bound resident memory. + * + * @type {Map} + */ +const payloadCache = new Map(); +const MAX_CACHED_PAYLOAD_BYTES = 4 * 1024 * 1024; + +/** @param {number} sizeBytes */ +function payload(sizeBytes) { + const n = Math.max(0, Math.floor(sizeBytes)); + if (n > MAX_CACHED_PAYLOAD_BYTES) { + // Don't cache huge payloads; just synthesize and discard. + return "x".repeat(n); + } + let s = payloadCache.get(n); + if (!s) { + s = "x".repeat(n); + payloadCache.set(n, s); + } + return s; +} + +/** + * Minimal request/response RPC server for the benchmark bundle. Speaks + * the same length-prefixed JSON framing as the production + * `ComapeoRpcServer` (via `SocketMessagePort`) so the native UDS layer + * is exercised identically — only the on-the-wire payload schema and + * the dispatch table differ. + * + * Wire format: + * request: { id: string, method: "echo" | "payload", params?: unknown } + * response: { id: string, result: unknown } + * | { id: string, error: { message: string } } + * + * Methods: + * - `echo(params)` returns `params` unchanged. Used for round-trip + * latency at the smallest payload class. + * - `payload({ sizeBytes })` returns an ASCII string of `sizeBytes` + * length. Used for per-size throughput measurements. + * + * Each request emits an `op:"rpc"` span on the supplied sink with the + * server-side handler duration in `durationMs` and the response payload + * size in `attrs.bytes`. RN-thread (round-trip) timing is recorded + * separately on the bench app side so end-to-end vs. server-only timing + * can be diffed. + */ +export class BenchRpcServer extends ServerHelper { + /** @param {{ sink: import("./telemetry-sink.js").TelemetrySink }} options */ + constructor({ sink }) { + super((socket) => this.#onConnection(socket)); + /** @type {import("./telemetry-sink.js").TelemetrySink} */ + this.sink = sink; + } + + /** @param {import('node:net').Socket} socket */ + #onConnection(socket) { + const port = new SocketMessagePort(socket); + port.on("message", (msg) => this.#handleRequest(port, msg)); + port.on("messageerror", (err) => { + console.error("BenchRpcServer: client sent invalid message", err); + }); + port.start(); + } + + /** + * @param {SocketMessagePort} port + * @param {unknown} msg + */ + #handleRequest(port, msg) { + if ( + !msg || + typeof msg !== "object" || + typeof (/** @type {any} */ (msg).id) !== "string" || + typeof (/** @type {any} */ (msg).method) !== "string" + ) { + console.warn("BenchRpcServer: malformed request, ignoring", msg); + return; + } + const { id, method, params } = + /** @type {{ id: string, method: string, params?: unknown }} */ (msg); + + const span = startSpan(this.sink, "rpc", `rpc.${method}`); + /** @type {unknown} */ + let result; + /** @type {{ message: string } | undefined} */ + let error; + try { + result = this.#invoke(method, params); + } catch (e) { + error = { message: e instanceof Error ? e.message : String(e) }; + } + const responseBytes = + typeof result === "string" + ? result.length + : result == null + ? 0 + : JSON.stringify(result).length; + span.end({ bytes: responseBytes, error: !!error }); + + port.postMessage(error ? { id, error } : { id, result }); + } + + /** + * @param {string} method + * @param {unknown} params + */ + #invoke(method, params) { + switch (method) { + case "echo": + return params ?? null; + case "payload": { + const sizeBytes = + params && + typeof params === "object" && + "sizeBytes" in params && + typeof (/** @type {any} */ (params).sizeBytes) === "number" + ? /** @type {{ sizeBytes: number }} */ (params).sizeBytes + : 64; + return payload(sizeBytes); + } + default: + throw new Error(`Unknown bench RPC method: ${method}`); + } + } +} diff --git a/backend/lib/boot-spans.js b/backend/lib/boot-spans.js new file mode 100644 index 0000000..f16f919 --- /dev/null +++ b/backend/lib/boot-spans.js @@ -0,0 +1,45 @@ +import { startSpan } from "./telemetry-sink.js"; + +/** + * Names of every boot phase the Sentry plan §7.4.2 enumerates. Re-used + * by the bench backend (see `backend/index.bench.js`) and intended to + * be picked up by the production `backend/index.js` when Sentry plan + * Phase 3 lands. Keeping the names identical means the same dashboards + * work for both transports. + * + * Three of the six are server-side (Node) and three are native-side: + * + * server-side (Node measures these): + * - listen-control + * - init + * - construct + * + * native-side (Android/iOS measure these; deferred to Sentry plan): + * - ipc-connect (control) + * - rootkey-load + * - ipc-connect (comapeo) + * + * The bench backend only records the three server-side phases; the + * native-side phases will be added when the production loaders adopt + * shared instrumentation. + * + * @typedef {"listen-control" + * | "ipc-connect (control)" + * | "rootkey-load" + * | "init" + * | "construct" + * | "ipc-connect (comapeo)"} BootPhase + */ + +/** + * Open a `boot.` span on the given sink. Thin wrapper over + * `startSpan` that fixes the `op` to `"boot"` and prefixes the name — + * keeps every call site uniform and the phase name authoritative. + * + * @param {import("./telemetry-sink.js").TelemetrySink} sink + * @param {BootPhase} phase + * @param {Record} [attrs] + */ +export function startBootSpan(sink, phase, attrs) { + return startSpan(sink, "boot", `boot.${phase}`, attrs); +} diff --git a/backend/lib/telemetry-sink.js b/backend/lib/telemetry-sink.js new file mode 100644 index 0000000..d72fe50 --- /dev/null +++ b/backend/lib/telemetry-sink.js @@ -0,0 +1,161 @@ +import { appendFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; + +/** + * Telemetry sink interface used by the bench backend (and, eventually, + * by the production backend once Sentry plan §6.x lands a + * `SentryAdapterSink` implementing the same surface). + * + * Span shape: + * { + * op: "boot" | "rpc", + * name: "boot.listen-control" | "boot.init" | "boot.construct" + * | "rpc.echo" | "rpc.payload" | ..., + * startTimestamp: number, // ms since epoch + * durationMs: number, // sub-ms precision via process.hrtime.bigint + * attrs: object, // free-form per-call metadata (e.g. {bytes:1024}) + * } + * + * Implementations MUST be non-throwing on `recordSpan` — a bad sink + * cannot crash the backend mid-bench. Errors are logged and swallowed. + * + * @typedef {{ + * op: "boot" | "rpc", + * name: string, + * startTimestamp: number, + * durationMs: number, + * attrs?: Record, + * }} BenchSpan + * + * @typedef {{ + * recordSpan(span: BenchSpan): void, + * close(): Promise | void, + * }} TelemetrySink + */ + +/** + * Default sink. Useful when telemetry is irrelevant to a given run + * (e.g. local debugging where the bench app's on-device renderer is + * the only consumer). Zero-overhead. + * + * @returns {TelemetrySink} + */ +export class NoopSink { + recordSpan() {} + close() {} +} + +/** + * NDJSON-to-disk sink. One span per line; appends are sync so a process + * crash mid-bench doesn't lose buffered spans. Used as the default + * on-device transport: the bench app reads the file back to render the + * results panel and offer "Export results". + * + * @returns {TelemetrySink} + */ +export class JsonFileSink { + /** @param {string} filePath */ + constructor(filePath) { + this.filePath = filePath; + mkdirSync(path.dirname(filePath), { recursive: true }); + } + + /** @param {BenchSpan} span */ + recordSpan(span) { + try { + appendFileSync(this.filePath, JSON.stringify(span) + "\n"); + } catch (e) { + console.error("JsonFileSink: append failed", e); + } + } + + close() {} +} + +/** + * Fire-and-forget HTTP POST sink. Intended for orchestrated runs where + * the bench app POSTs to a host-side receiver via the BrowserStack + * Local tunnel. Failures are silently swallowed so the on-device + * experience is unaffected when no receiver is reachable — that's + * deliberate: the bench app must be useful standalone. + * + * @returns {TelemetrySink} + */ +export class HttpSink { + /** @param {string} url */ + constructor(url) { + this.url = url; + } + + /** @param {BenchSpan} span */ + recordSpan(span) { + // No await — we don't want sink latency on the hot path. Errors are + // logged once per type to avoid flooding the console when the + // receiver is down. + fetch(this.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(span), + }).catch((e) => { + if (!this._loggedError) { + console.warn("HttpSink: POST failed (subsequent errors suppressed):", e?.message ?? e); + this._loggedError = true; + } + }); + } + + close() {} +} + +/** + * Parses a `--telemetry=` CLI arg into a sink instance. + * + * - `noop` → NoopSink (also the fallback for unspecified) + * - `file:` → JsonFileSink writing NDJSON to `` + * - `http://` → HttpSink POSTing each span as JSON + * - `https://` → ditto + * + * Unknown specs throw at startup so a typo doesn't silently drop spans. + * + * @param {string | undefined} spec + * @returns {TelemetrySink} + */ +export function createSinkFromArg(spec) { + if (!spec || spec === "noop") return new NoopSink(); + if (spec.startsWith("file:")) return new JsonFileSink(spec.slice("file:".length)); + if (spec.startsWith("http://") || spec.startsWith("https://")) return new HttpSink(spec); + throw new Error( + `Unknown --telemetry spec: ${spec}. Expected "noop", "file:", or "http(s)://".`, + ); +} + +/** + * Open a span. Returns an object with `.end(extraAttrs?)` that records + * the span on the sink with measured duration. + * + * Uses `process.hrtime.bigint()` for sub-ms precision and Date.now() for + * the wall-clock start timestamp (so spans can be correlated across + * device clock skew when host-side aggregation is used). + * + * @param {TelemetrySink} sink + * @param {"boot" | "rpc"} op + * @param {string} name + * @param {Record} [attrs] + */ +export function startSpan(sink, op, name, attrs = {}) { + const startTimestamp = Date.now(); + const startHr = process.hrtime.bigint(); + return { + /** @param {Record} [extraAttrs] */ + end(extraAttrs) { + const elapsedNs = Number(process.hrtime.bigint() - startHr); + sink.recordSpan({ + op, + name, + startTimestamp, + durationMs: elapsedNs / 1e6, + attrs: extraAttrs ? { ...attrs, ...extraAttrs } : attrs, + }); + }, + }; +} diff --git a/backend/rollup.config.ts b/backend/rollup.config.ts index 61e9667..fadda75 100644 --- a/backend/rollup.config.ts +++ b/backend/rollup.config.ts @@ -38,6 +38,26 @@ const ANDROID_OUT_MAIN = const IOS_OUT = process.env.OUTPUT_DIR_IOS ?? path.join(__dirname, "dist/ios"); +/** + * `BENCH=1` switches this config to emit the bench-only bundle + * (`index.bench.js`) into the bench-specific output trees: + * - `android/src/bench/assets/nodejs-project/` (overlaid by the + * `bench` Android productFlavor — see android/build.gradle) + * - `ios/nodejs-project-bench/` (picked up by `ComapeoCore.podspec` + * iff `ENV['COMAPEO_BENCH']` is set at pod install time) + * + * Default (no env var) is unchanged: production `index.js` to the + * existing main/debug/iOS paths. + */ +const IS_BENCH = process.env.BENCH === "1"; + +const ANDROID_BENCH_OUT = + process.env.OUTPUT_DIR_ANDROID_BENCH ?? + path.join(__dirname, "dist/android/bench"); + +const IOS_BENCH_OUT = + process.env.OUTPUT_DIR_IOS_BENCH ?? path.join(__dirname, "dist/ios/bench"); + /** * Resolves `@comapeo/core`'s `./fastify-plugins/maps.js` import to the * iOS-only no-op stub. Scoped tightly to the `@comapeo/core/src/` importer @@ -87,17 +107,26 @@ const STATIC_ASSET_PATHS = [ "node_modules/@comapeo/fallback-smp", ] as const; +/** + * Bench-bundle static assets. The bench backend doesn't import + * `@comapeo/core` so none of the production runtime data files + * (drizzle SQL, default-categories zip, fallback map) are reachable + * from `index.bench.js`. Only the `package.json` is needed — Node's + * module resolver reads it to set the unpacked tree's module type. + */ +const BENCH_STATIC_ASSET_PATHS = ["package.json"] as const; + /** * Copies the static asset paths from `backend/` into `outDir` after the * rollup write completes. Replaces the per-platform staging copy that * `scripts/build-backend.ts` used to do. */ -function copyStaticAssetsPlugin(outDir: string): Plugin { +function copyStaticAssetsPlugin(outDir: string, paths: readonly string[]): Plugin { return { name: "copy-static-assets", async writeBundle() { await Promise.all( - STATIC_ASSET_PATHS.map((rel) => + paths.map((rel) => cp(path.join(__dirname, rel), path.join(outDir, rel), { recursive: true, }), @@ -111,10 +140,14 @@ function buildPlugins({ platform, outDir, shouldMinify, + staticAssetPaths, + isBench, }: { platform: "android" | "ios"; outDir: string; shouldMinify: boolean; + staticAssetPaths: readonly string[]; + isBench: boolean; }): Plugin[] { return [ alias({ @@ -128,8 +161,9 @@ function buildPlugins({ ], }), // iOS-only: stub the maps fastify plugin so undici stays out of the - // bundle. See lib/maps-stub.js. - ...(platform === "ios" ? [stubComapeoMapsPlugin()] : []), + // bundle. See lib/maps-stub.js. Bench bundle doesn't import + // `@comapeo/core` at all, so no stub is needed. + ...(platform === "ios" && !isBench ? [stubComapeoMapsPlugin()] : []), // 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 @@ -144,7 +178,7 @@ function buildPlugins({ // @ts-expect-error Types for these rollup plugins are misconfigured: https://github.com/rollup/plugins/issues/1860 json(), shouldMinify ? minify() : undefined, - copyStaticAssetsPlugin(outDir), + copyStaticAssetsPlugin(outDir, staticAssetPaths), ]; } @@ -163,10 +197,14 @@ function cleanOutputDirPlugin(dir: string): Plugin { }; } -const sharedInput = { +const prodInput = { index: path.join(__dirname, "index.js"), }; +const benchInput = { + index: path.join(__dirname, "index.bench.js"), +}; + const sharedOutput: OutputOptions = { format: "esm", sourcemap: true, @@ -174,12 +212,22 @@ const sharedOutput: OutputOptions = { }; /** - * Three outputs from the same source tree: Android debug, Android release, and iOS. - * Android gets the full bundle — its nodejs-mobile build permits JIT, so undici - * (and therefore the maps fastify plugin) loads cleanly. iOS gets the same bundle but with - * `@comapeo/core`'s maps plugin swapped for a no-op (see lib/maps-stub.js) - * because nodejs-mobile iOS runs V8 with `--jitless` and undici's - * WebAssembly init would crash module load. + * Production: three outputs from the same source tree (Android debug, + * Android release, iOS). Android gets the full bundle — its + * nodejs-mobile build permits JIT, so undici (and therefore the maps + * fastify plugin) loads cleanly. iOS gets the same bundle but with + * `@comapeo/core`'s maps plugin swapped for a no-op (see + * lib/maps-stub.js) because nodejs-mobile iOS runs V8 with `--jitless` + * and undici's WebAssembly init would crash module load. + * + * Bench: a separate two-output mode keyed off `BENCH=1`. Same banner / + * loader machinery so the native addon system works identically, but + * the entry is `index.bench.js` (which doesn't import `@comapeo/core`) + * and the static-asset copy is trimmed to just `package.json`. Bench + * outputs land in flavor-specific paths (`android/src/bench/...`, + * `ios/nodejs-project-bench/`) that production consumers never see; + * see android/build.gradle and ios/ComapeoCore.podspec for the + * consumer-side wiring. * * Each output's `banner` defines `__loadAddon(name, version)` with the * platform-appropriate `process.dlopen` target — Android does @@ -187,9 +235,9 @@ const sharedOutput: OutputOptions = { * Embed-&-Sign'd xcframework binary at NATIVE_LIB_DIR/.framework/. * See `rollup-plugin-addon-loader.js` for the helper bodies. */ -const config: RollupOptions[] = [ +const prodConfig: RollupOptions[] = [ { - input: sharedInput, + input: prodInput, output: { ...sharedOutput, dir: ANDROID_OUT_DEBUG, @@ -202,11 +250,13 @@ const config: RollupOptions[] = [ outDir: ANDROID_OUT_DEBUG, // Android debug does not minify the bundle. shouldMinify: false, + staticAssetPaths: STATIC_ASSET_PATHS, + isBench: false, }), ], }, { - input: sharedInput, + input: prodInput, output: { ...sharedOutput, dir: ANDROID_OUT_MAIN, @@ -218,11 +268,13 @@ const config: RollupOptions[] = [ platform: "android", outDir: ANDROID_OUT_MAIN, shouldMinify: true, + staticAssetPaths: STATIC_ASSET_PATHS, + isBench: false, }), ], }, { - input: sharedInput, + input: prodInput, output: { ...sharedOutput, dir: IOS_OUT, @@ -234,9 +286,50 @@ const config: RollupOptions[] = [ platform: "ios", outDir: IOS_OUT, shouldMinify: true, + staticAssetPaths: STATIC_ASSET_PATHS, + isBench: false, + }), + ], + }, +]; + +const benchConfig: RollupOptions[] = [ + { + input: benchInput, + output: { + ...sharedOutput, + dir: ANDROID_BENCH_OUT, + banner: androidAddonLoaderBanner, + }, + plugins: [ + cleanOutputDirPlugin(ANDROID_BENCH_OUT), + ...buildPlugins({ + platform: "android", + outDir: ANDROID_BENCH_OUT, + shouldMinify: true, + staticAssetPaths: BENCH_STATIC_ASSET_PATHS, + isBench: true, + }), + ], + }, + { + input: benchInput, + output: { + ...sharedOutput, + dir: IOS_BENCH_OUT, + banner: iosAddonLoaderBanner, + }, + plugins: [ + cleanOutputDirPlugin(IOS_BENCH_OUT), + ...buildPlugins({ + platform: "ios", + outDir: IOS_BENCH_OUT, + shouldMinify: true, + staticAssetPaths: BENCH_STATIC_ASSET_PATHS, + isBench: true, }), ], }, ]; -export default config; +export default IS_BENCH ? benchConfig : prodConfig; diff --git a/docs/uds-rpc-bridge-benchmark-plan.md b/docs/uds-rpc-bridge-benchmark-plan.md new file mode 100644 index 0000000..f32193f --- /dev/null +++ b/docs/uds-rpc-bridge-benchmark-plan.md @@ -0,0 +1,322 @@ +# UDS / RPC Bridge Benchmark Suite + +## Context + +`@comapeo/core-react-native` connects React Native to a `nodejs-mobile` +runtime over a pair of UNIX-domain sockets (control + comapeo) with a +length-prefixed JSON RPC framing. We want to measure two things on real +devices via **BrowserStack App Automate**: + +1. **UDS connection initialisation** — the boot phases already named in + `backend/index.js` and modelled by the Sentry plan: `listen-control`, + `ipc-connect (control)`, `rootkey-load`, `init`, `construct`, + `ipc-connect (comapeo)`. +2. **RPC bridge performance** — round-trip latency for small messages and + throughput at varying payload sizes (e.g. 64B / 1KB / 64KB / 1MB), both + cold and steady-state. + +This is **phase 1**. Real `@comapeo/core` API benchmarks (project ops, sync, +sqlite) come later; here we want to isolate the parts of the bridge that +*we* own so device-specific regressions in framing / IPC / RPC plumbing +surface without `@comapeo/core` noise. The repo currently has **zero** +benchmark or timing instrumentation; the Sentry plan +(`docs/sentry-integration-plan.md`) is detailed but unimplemented. + +## Approaches considered + +- **Sentry-based (rejected as transport):** the Sentry plan defines exactly + the spans we want (`comapeo.boot` with phase children, `op:"rpc"` named by + `request.method`). But Sentry is sample-based, has per-span overhead, ships + via HTTPS to a remote service, and Phases 1–3 of the plan are unimplemented + prerequisites. Wrong transport for tight-loop microbenchmarks. **Reuse the + taxonomy, not the implementation.** +- **Native test runner only (rejected as primary):** `androidx.benchmark` / + XCTest `measure` blocks would run on BrowserStack natively and dump + reports without custom transport — but they don't exercise the + RN→native→Node JS path, only the native↔Node leg. Insufficient for RPC + round-trip timing as users feel it. +- **Standalone benchmark app + custom JS bundle (chosen):** isolates the + bits we own from `@comapeo/core` init noise; lets us drive the bridge + through the real RN→native→Node path; matches the existing aspirational + `e2e/.maestro/ipc-roundtrip.yaml` (which already references `send-button` / + `benchmark-result` IDs that `App.tsx` doesn't yet have). + +## Recommended design + +### Single benchmark app + stripped backend, with shared Sentry-shaped instrumentation + +- A new `apps/benchmark/` is a slim copy of `apps/example/`. UI exposes + payload-size selectors, a Send button (`testID="send-button"`), and a result + panel (`testID="benchmark-result"`). +- A new `backend/index.bench.js` is the bench-only nodejs-mobile entry. It + reuses the same `pre-listening` → `started` → `ready` state machine as + `backend/index.js` so the native module is unchanged, but **does not import + `@comapeo/core`** and registers only `echo` and `payload(sizeBytes)` RPC + methods. +- `scripts/build-backend.ts` learns a `--bench` mode that emits the bench + bundle into bench-only sibling paths (see "Consumer isolation" below) so it + cannot leak into a regular consumer app. +- A pluggable telemetry sink emits the Sentry plan's exact span shape + (`comapeo.boot` transaction with `boot.listen-control`, + `boot.ipc-connect (control)`, `boot.rootkey-load`, `boot.init`, + `boot.construct`, `boot.ipc-connect (comapeo)`, plus `op:"rpc"` spans named + `request.method.join(".")`). When Sentry plan Phase 3 lands, a single + `SentryAdapterSink` (~30 LOC) implementing the same `recordSpan` interface + drops in without changing call sites. +- Production `backend/index.js` is **not modified** in this phase. The shared + helpers live in `backend/lib/` and are consumed by `index.bench.js` only; + the Sentry plan can adopt them later. +- Maestro flows drive runs on BrowserStack App Automate. +- Results escape via **HTTP POST through the BrowserStack Local tunnel** to a + small Node receiver (`scripts/lib/bench-receiver.ts`) running on the + BrowserStack runner. + +## Consumer isolation (bench bundle ships only to the bench app) + +Hard requirement: a regular consumer (the example app, third-party apps +installing `@comapeo/core-react-native` from npm) MUST NOT receive any bench +artefacts in their APK or IPA. Three independent guards enforce this: + +1. **Path isolation in the working tree.** Production assets land at + `android/src/main/assets/nodejs-project/` and `ios/nodejs-project/` — + exactly the locations the AAR `sourceSets.main.assets` and the + podspec's `s.resources = ['nodejs-project']` already pick up + (`android/build.gradle:93-104`, `ios/ComapeoCore.podspec:48`). Bench + assets land at sibling paths the production module's gradle/podspec + does **not** reference by default: + - `android/src/bench/assets/nodejs-project/` *(new build variant + sourceSet, off by default)* + - `ios/nodejs-project-bench/` *(included only when the consumer's + Podfile sets `ENV['COMAPEO_BENCH'] = '1'` before pod install)* +2. **Default-off opt-in at consumer build time, driven by an Expo config + plugin (no checked-in `android/`/`ios/` folders).** + - The bench app declares its native wiring entirely in `app.json` / + `app.config.js` via a single config plugin + `./plugins/with-comapeo-bench`. `expo prebuild` regenerates the + `android/` and `ios/` directories on demand; nothing under those + paths is checked into git for `apps/benchmark/`. + - Android: the plugin uses `withAppBuildGradle` to append + `flavorDimensions += "comapeo"` and + `missingDimensionStrategy 'comapeo', 'bench'` to the bench app's + `android/app/build.gradle` `defaultConfig`. The module's own + `android/build.gradle` declares the `bench` flavor + sourceSet; + consumers that don't activate it (`apps/example/`, third-party + apps) get the default flavor and never see `src/bench/`. + - iOS: the plugin uses `withPodfile` (the canonical + `@expo/config-plugins` mod for Podfile string edits) to prepend + `ENV['COMAPEO_BENCH'] = '1'` to the regenerated `ios/Podfile` + above the autolinking block, so the env var is set before pod + install reads `ComapeoCore.podspec`. The module's + `ComapeoCore.podspec` reads that env var at `pod install` time and + conditionally appends `nodejs-project-bench` to `s.resources`. + With no env var, the podspec ships only the production + `nodejs-project/`. Each mutation guards with an `includes(sentinel)` + check so re-runs of `expo prebuild` are idempotent (string-based + mods compose poorly without this — Expo docs explicitly warn about + it). `withDangerousMod` is reserved as an escape hatch and is not + needed here. Note: `expo-build-properties.ios.extraPods` cannot + express a subspec opt-in (no `:subspecs` field) and only appends — + it cannot override the autolinked `pod 'ComapeoCore'` entry — which + is why the env-var-driven podspec is the right shape. +3. **Publish-time exclusion.** `package.json`'s `files` array does not + list `android/src/bench/` or `ios/nodejs-project-bench/`, so even if a + developer accidentally runs `--bench` before publishing, those paths + are physically excluded from the npm tarball. The bench app consumes + the working tree via Expo autolinking from `../..`, not from the + published package, so it still works locally. + +Net effect: a consumer running `npm install @comapeo/core-react-native` +followed by `expo prebuild` gets exactly today's APK/IPA. A consumer +that has the working tree locally but doesn't apply the +`with-comapeo-bench` plugin also gets exactly today's APK/IPA. Only the +bench app, whose `app.json` lists the plugin, links the bench bundle. + +### Native loader behaviour under the bench variant + +`nodejs-mobile` boots from a fixed `nodejs-project/` path inside the app +sandbox. To avoid changing the native loader, the bench variant +substitutes the bundle in place: + +- Android: AGP's per-variant asset overlay replaces files in + `assets/nodejs-project/` with the bench versions when the `bench` + flavor is active, because the bench sourceSet writes the bundle to the + same relative path (`nodejs-project/`) under its own `src/bench/assets` + root. Production builds never see `src/bench/`. +- iOS: the podspec packages `nodejs-project-bench/` as a separate + resource bundle when `ENV['COMAPEO_BENCH']` is set. A small build + phase (added by the `with-comapeo-bench` plugin via + `withXcodeProject`) renames it to `nodejs-project/` in the embedded + app bundle at copy time. The default build ships only the production + `nodejs-project/`. + +Both variants leave the existing `NodeJSService.swift` and Android Node +launcher unchanged. + +## Standalone operation + +The bench app must be useful without any host-side infrastructure: a +developer should be able to `expo prebuild`, install the APK/IPA on any +device, tap Send, and read results on screen. Concretely: + +- The default sink is `JsonFileSink` writing to the app's Documents + directory (`/Documents/comapeo-bench/.ndjson`) plus an + on-screen render in the `benchmark-result` panel: per-phase boot + durations, per-payload-size RPC p50/p95/p99 over a fixed iteration + count. +- The `HttpSink` is **opt-in**, controlled by a UI toggle and an + optional URL field defaulting to `http://localhost:`. It posts + in addition to (not instead of) the on-device render, and any + network failure is logged and ignored — the on-device experience is + unchanged. +- An "Export results" button on the result panel reveals the file path + and (on iOS) opens the system share sheet so a user can pull NDJSON + off the device without `adb pull` / Xcode access. Useful for ad-hoc + device testing. +- A timestamped run id is shown on screen so screenshots from manual + runs can be cross-referenced if needed. + +## Release-variant correctness + +Real-app perf-feel is debug-misleading (interpreter JS, no R8/ProGuard, +unminified RN bundle). Both build types must work end-to-end: + +- Android: the `bench` productFlavor is orthogonal to `debug` / + `release` build types. The bench sourceSet only adds assets, which + R8/ProGuard never touch, so a `release` variant of the bench app + bundles the bench `nodejs-project/` exactly as `debug` does. + Verification: `eas build --profile production-apk --platform android` + (or `./gradlew :app:assembleBenchRelease`) and unzip-grep the APK. +- iOS: the resource toggle via `ENV['COMAPEO_BENCH']` runs at + `pod install` time, before any per-configuration build, so Release + and Debug configurations both embed the bench bundle identically. + Verification: archive the bench app with the Release configuration + and inspect `.app/nodejs-project/`. + +## Critical files + +**Shared instrumentation (used now by `index.bench.js`, reused by Sentry Phase 3 later):** +- `backend/lib/telemetry-sink.js` *(new)* — `recordSpan({op, name, startNs, endNs, attrs})` interface plus `JsonFileSink` / `HttpSink` / `NoopSink` implementations. +- `backend/lib/boot-spans.js` *(new)* — helpers that wrap the four phase blocks (`listen-control`, `init`, `construct`, `ipc-connect (comapeo)`) plus `ipc-connect (control)` and `rootkey-load` with `recordSpan({ op:"boot", name:"boot." })`. Phase names mirror the existing `Object.assign(e, { phase })` tags in `backend/index.js` and the Sentry plan §7.4.2. + +**Bench backend entry:** +- `backend/index.bench.js` *(new)* — listens on the same control + comapeo socket paths, runs the same state machine, but skips `createComapeo` and registers `echo` / `payload(sizeBytes)`. Wires the boot-span helpers and exposes per-RPC timing via `comapeo-rpc`-equivalent server. +- `backend/lib/bench-rpc.js` *(new)* — minimal RPC server that accepts the bench methods and emits `op:"rpc"` spans on each request via the shared sink. +- `scripts/build-backend.ts` — add `--bench` mode. In bench mode rollup is invoked with `INPUT=index.bench.js` and `OUTPUT_DIR_*` pointing at `android/src/bench/assets/nodejs-project/` and `ios/nodejs-project-bench/` (NOT the production `src/main/` and `ios/nodejs-project/` paths). Default mode is unchanged. +- `backend/rollup.config.ts` — accept the entry override; exclude `@comapeo/core` and its drizzle migrations from the bench bundle. + +**Module-side wiring for the bench variant:** +- `android/build.gradle` — declare `flavorDimensions "comapeo"`, a `productFlavors { production {}; bench {} }` block, and a `sourceSets.bench { assets.srcDirs 'src/bench/assets' }`. Default consumer (`apps/example/` and third parties) compiles `production` only; the bench sourceSet is ignored. Bench app activates the `bench` flavor via `missingDimensionStrategy` injected by its config plugin. +- `ios/ComapeoCore.podspec` — read `ENV['COMAPEO_BENCH']` at evaluation time and, when set, append `nodejs-project-bench` to `s.resources`. Default consumers leave the env var unset and ship the existing single `nodejs-project` resource. +- `package.json` — `files` array stays as-is; `android/src/bench/` and `ios/nodejs-project-bench/` are deliberately omitted so they cannot leak via `npm publish`. + +**Bench app (no checked-in `android/`/`ios/`):** +- `apps/benchmark/` *(new)* — slim sibling of `apps/example/`, but only owns: `App.tsx`, `app.json`, `babel.config.js`, `metro.config.js`, `index.ts`, `package.json`, `tsconfig.json`, and `plugins/`. Native dirs are not checked in; `expo prebuild` generates them on demand using the config plugin below. Uses Expo autolinking back to `../..`, same pattern as `apps/example`. +- `apps/benchmark/app.json` *(new)* — declares the bench plugin **only**: `"plugins": ["./plugins/with-comapeo-bench"]`. Does **not** include `with-android-tests` or `with-ios-tests` from `apps/example/plugins/`. +- `apps/benchmark/App.tsx` *(new)* — UI with `testID="send-button"`, `testID="benchmark-result"`, payload-size selector, warmup/steady-state toggle, on-screen p50/p95/p99 render, "Export results" button, and an opt-in "POST to receiver" toggle + URL field (default `http://localhost:`, off by default). +- `apps/benchmark/plugins/with-comapeo-bench/` *(new)* — single config plugin. Uses canonical `@expo/config-plugins` mods only (no `withDangerousMod` — research confirmed neither `expo-build-properties` nor any `@config-plugins/*` package covers product flavors or Podfile env-var injection, so a custom plugin is unavoidable, but the standard mods suffice): + - `withAppBuildGradle` — appends `flavorDimensions += "comapeo"` and `missingDimensionStrategy 'comapeo', 'bench'` inside `android.defaultConfig`. Also adds `matchingFallbacks = ['production']` on the `debug` and `release` build types to avoid the known Expo / AGP footgun where the consuming app's variants don't resolve against the module's `bench` flavor (expo/expo#18315, #16686, #23266). Each insertion guarded by an `includes(sentinel)` check for idempotency across `expo prebuild` re-runs. + - `withPodfile` — prepends `ENV['COMAPEO_BENCH'] = '1'` to `ios/Podfile` above the autolinking block. Same idempotency guard. + - `withXcodeProject` — adds the resource-rename build phase mapping `nodejs-project-bench/` → `nodejs-project/` in the embedded app bundle. + +**RN-side timing hook:** +- `src/ComapeoCoreModule.ts` (`CoreMessagePort`) — accept an optional JS-side `recordSpan` so RN-thread timestamps round-trip with each RPC. Same structural shape Sentry plan §6.2 will need later. + +**Maestro flows:** +- `e2e/.maestro/bench-rpc.yaml` *(new)* and per-payload-size flows (`bench-payload-64B.yaml`, `bench-payload-1KB.yaml`, `bench-payload-64KB.yaml`, `bench-payload-1MB.yaml`). + +**Receiver:** +- `scripts/lib/bench-receiver.ts` *(new)* — small Node HTTP receiver bound to localhost; collates incoming spans into per-device NDJSON and a CSV summary keyed by BrowserStack session id / device tag. + +## Results pipeline + +Two transports, ranked by who's running the bench: + +- **Default (always works, including offline):** `JsonFileSink` writes + NDJSON to the app's Documents directory and the app renders summary + stats on screen. An "Export results" button reveals the path / + triggers the share sheet on iOS. This is the only required transport + for a developer running the bench app standalone on any device. +- **Optional (orchestrated BrowserStack runs):** `HttpSink` POSTs every + span to a user-supplied URL (default `http://localhost:` reached + via BrowserStack Local tunnel). Toggle defaults to off. Connection + failures are silently logged so the on-device experience never + regresses when no receiver is listening. `bench-receiver.ts` writes + per-device NDJSON + CSV when in use. +- **No reliance on Sentry HTTPS upload.** + +## Phasing + +1. **Phase 1 (1–2 days):** shared sink (`telemetry-sink.js`, `boot-spans.js`) + + `backend/index.bench.js` skeleton with boot-phase spans wired. Verify + locally with `JsonFileSink` against a dev build. +2. **Phase 2 (2–3 days):** dual-bundle build wiring + consumer isolation + (`scripts/build-backend.ts --bench`, rollup config, Android `bench` + productFlavor in the module's `android/build.gradle`, env-driven + resource toggle in the module's `ios/ComapeoCore.podspec`). Confirm + production `nodejs-project/` is byte-identical to before; bench + bundle lands in `android/src/bench/...` / `ios/nodejs-project-bench/` + and is absent from a default `apps/example/` build (release variant + too). +3. **Phase 3 (3–5 days):** `apps/benchmark/` skeleton (no checked-in + `android/`/`ios/`) with `App.tsx` UI, RPC bridge wiring, per-payload-size + handlers, warmup/steady-state logic, on-screen p50/p95/p99 render, + "Export results" button, and the `with-comapeo-bench` config plugin. + Verify with `expo prebuild` followed by both **debug and release** + builds on Android and iOS that the bench bundle is embedded and the + app produces results standalone (with the HTTP toggle off). +4. **Phase 4 (2 days):** Maestro flows (`bench-rpc.yaml` + per-payload-size); + BrowserStack Local tunnel verified with at least three real devices (one + low-end Android, one mid-range Android, one iOS); per-device CSV produced. +5. **Phase 5 (later, when Sentry plan reaches Phase 3):** add + `SentryAdapterSink` implementing the same `recordSpan` interface; the + Sentry plan adopts `boot-spans.js` for the production `backend/index.js`. + No call-site changes here. + +## Out of scope (deferred) + +- `@comapeo/core` API benchmarks (`project.observation.create`, sync, sqlite). + The whole point of the stripped `index.bench.js` is to **avoid** measuring + these. +- Modifications to production `backend/index.js`. Stays untouched until the + Sentry plan adopts the shared helpers. +- Boot timing of the **real** backend including `@comapeo/core` init. We're + measuring the parts we own; full-stack production-feel boot timing is a + separate question (revisit after Sentry plan Phase 3 lands and gives us + that signal automatically). +- Memory benchmarks, sync session throughput, real Sentry transport. + +## Verification + +- **Phase 1:** run the bench backend locally with + `--telemetry=file:/tmp/boot.ndjson`; inspect six `boot.*` spans with sane + durations; confirm `--telemetry=noop` produces no behavioural diff. +- **Phase 2:** run `npm run backend:build` and `npm run backend:build -- --bench`; + confirm `android/src/main/assets/nodejs-project/` and `ios/nodejs-project/` + are unchanged (diff vs main) and that the bench bundle lands at + `android/src/bench/assets/nodejs-project/` + `ios/nodejs-project-bench/` + without `@comapeo/core` artefacts. **Consumer-isolation check:** build + `apps/example/` for Android (both debug and release) and iOS (both + configurations), then unzip the resulting APK / IPA and grep for + `index.bench` and `nodejs-project-bench` — all must be absent. Then run + `expo prebuild` in `apps/benchmark/` and build it for the same four + configurations; confirm the bench bundle IS present in its APK / IPA in + both debug and release. Finally run `npm pack` and inspect the + tarball: no `android/src/bench/` and no `ios/nodejs-project-bench/` + entries. +- **Phase 3 standalone check:** with the device offline (or with the + HTTP toggle off), launch the bench app, run a full sweep, hit + "Export results", and confirm the NDJSON file exists at the displayed + path and the on-screen p50/p95/p99 numbers are populated. Repeat with + a release build to confirm release-mode timings are produced. +- **Phase 3:** run `apps/benchmark` locally on an Android emulator + iOS + simulator with `bench-receiver.ts` listening on `127.0.0.1:`; tap Send + through each payload-size selector; confirm spans arrive (one `op:"boot"` + transaction at launch, `op:"rpc"` spans per Send, with `attrs.bytes` + matching the selected size). +- **Phase 4:** submit to BrowserStack App Automate with three devices; confirm + per-device NDJSON arrives in the receiver and that distinct device tags + appear; eyeball the CSV summary for plausible per-device latency + differences. +- **Phase 5 (when Sentry lands):** flip the sink at one call site and confirm + Sentry dashboards show the same `boot.*` and `op:"rpc"` spans without code + changes elsewhere. diff --git a/e2e/.maestro/bench-payload-1KB.yaml b/e2e/.maestro/bench-payload-1KB.yaml new file mode 100644 index 0000000..3395ed2 --- /dev/null +++ b/e2e/.maestro/bench-payload-1KB.yaml @@ -0,0 +1,28 @@ +appId: com.comapeo.core.benchmark +--- +# Per-payload-size variant of bench-rpc.yaml: only 1KB selected. + +- launchApp: + clearState: true + +- extendedWaitUntil: + visible: + id: "service-state" + text: "STARTED" + timeout: 60000 + +- tapOn: + id: "size-64" +- tapOn: + id: "size-65536" + +- tapOn: + id: "send-button" + +- extendedWaitUntil: + visible: + id: "benchmark-result" + timeout: 60000 + +- assertVisible: + text: "1KB" diff --git a/e2e/.maestro/bench-payload-1MB.yaml b/e2e/.maestro/bench-payload-1MB.yaml new file mode 100644 index 0000000..fd02e4c --- /dev/null +++ b/e2e/.maestro/bench-payload-1MB.yaml @@ -0,0 +1,35 @@ +appId: com.comapeo.core.benchmark +--- +# Per-payload-size variant of bench-rpc.yaml: only 1MB selected. Note +# 1MB is NOT in the default selection — the flow deselects the three +# defaults and selects 1MB explicitly. Timeout is bumped because each +# round-trip moves a megabyte through the bridge. + +- launchApp: + clearState: true + +- extendedWaitUntil: + visible: + id: "service-state" + text: "STARTED" + timeout: 60000 + +- tapOn: + id: "size-64" +- tapOn: + id: "size-1024" +- tapOn: + id: "size-65536" +- tapOn: + id: "size-1048576" + +- tapOn: + id: "send-button" + +- extendedWaitUntil: + visible: + id: "benchmark-result" + timeout: 180000 + +- assertVisible: + text: "1MB" diff --git a/e2e/.maestro/bench-payload-64B.yaml b/e2e/.maestro/bench-payload-64B.yaml new file mode 100644 index 0000000..c268c17 --- /dev/null +++ b/e2e/.maestro/bench-payload-64B.yaml @@ -0,0 +1,30 @@ +appId: com.comapeo.core.benchmark +--- +# Per-payload-size variant of bench-rpc.yaml: only 64B selected. +# Default selection is {64B, 1KB, 64KB}; deselect the two larger sizes +# so the run records 64B-only RTT distribution. + +- launchApp: + clearState: true + +- extendedWaitUntil: + visible: + id: "service-state" + text: "STARTED" + timeout: 60000 + +- tapOn: + id: "size-1024" +- tapOn: + id: "size-65536" + +- tapOn: + id: "send-button" + +- extendedWaitUntil: + visible: + id: "benchmark-result" + timeout: 60000 + +- assertVisible: + text: "64B" diff --git a/e2e/.maestro/bench-payload-64KB.yaml b/e2e/.maestro/bench-payload-64KB.yaml new file mode 100644 index 0000000..dd502f7 --- /dev/null +++ b/e2e/.maestro/bench-payload-64KB.yaml @@ -0,0 +1,28 @@ +appId: com.comapeo.core.benchmark +--- +# Per-payload-size variant of bench-rpc.yaml: only 64KB selected. + +- launchApp: + clearState: true + +- extendedWaitUntil: + visible: + id: "service-state" + text: "STARTED" + timeout: 60000 + +- tapOn: + id: "size-64" +- tapOn: + id: "size-1024" + +- tapOn: + id: "send-button" + +- extendedWaitUntil: + visible: + id: "benchmark-result" + timeout: 90000 + +- assertVisible: + text: "64KB" diff --git a/e2e/.maestro/bench-rpc.yaml b/e2e/.maestro/bench-rpc.yaml new file mode 100644 index 0000000..b0287e6 --- /dev/null +++ b/e2e/.maestro/bench-rpc.yaml @@ -0,0 +1,36 @@ +appId: com.comapeo.core.benchmark +--- +# UDS / RPC bridge benchmark — bench app launches its own bench backend +# (`backend/index.bench.js`, embedded via the `bench` Android flavor / +# `ENV['COMAPEO_BENCH']` iOS opt-in), waits for the service to reach +# STARTED, runs a sweep across the default payload sizes, and reads back +# the on-screen p50/p95/p99 panel. Spans are also written to the app's +# documents directory; "Export results" opens the system share sheet. +# +# This flow is the primary BrowserStack App Automate entry point. For +# orchestrated runs that ship spans to a host-side `bench-receiver.ts`, +# tap the "POST spans" toggle before tapping "Run benchmark". + +- launchApp: + clearState: true + +- extendedWaitUntil: + visible: + id: "service-state" + text: "STARTED" + timeout: 60000 + +- tapOn: + id: "send-button" + +- extendedWaitUntil: + visible: + id: "benchmark-result" + timeout: 120000 + +- assertVisible: + id: "benchmark-result" +- assertVisible: + text: "p50" +- assertVisible: + text: "p99" diff --git a/ios/ComapeoCore.podspec b/ios/ComapeoCore.podspec index b06e964..7330aef 100644 --- a/ios/ComapeoCore.podspec +++ b/ios/ComapeoCore.podspec @@ -45,5 +45,23 @@ Pod::Spec.new do |s| # of truth lives under `backend/` at the repo root. Native `.node` files # ship separately via `Frameworks/*.xcframework` (above) and are embedded # + codesigned by Xcode automatically. - s.resources = ['nodejs-project'] + # + # Bench opt-in: when `ENV['COMAPEO_BENCH']` is set at `pod install` time, + # the bench-only resource bundle (`nodejs-project-bench/`, produced by + # `scripts/build-backend.ts --bench`) REPLACES the production + # `nodejs-project/` in the resources list. The `with-comapeo-bench` + # Expo config plugin in `apps/benchmark/` flips that env var by + # prepending `ENV['COMAPEO_BENCH'] = '1'` to the consuming app's + # Podfile via `withPodfile`. Default consumers (`apps/example/`, third + # parties) leave the env var unset and ship only the production + # `nodejs-project/`. The bench bundle is then renamed to + # `nodejs-project/` in the embedded app bundle by a Run Script build + # phase the same plugin adds via `withXcodeProject` — so the native + # loader continues to look at a fixed `nodejs-project/` path regardless + # of which flavor is active. + if ENV['COMAPEO_BENCH'] == '1' + s.resources = ['nodejs-project-bench'] + else + s.resources = ['nodejs-project'] + end end diff --git a/package.json b/package.json index 821c212..5cce035 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "android/libnode/", "build/", "ios/", - "src/" + "src/", + "!android/src/bench/", + "!ios/nodejs-project-bench/" ], "devEngines": { "packageManager": { diff --git a/scripts/build-backend.ts b/scripts/build-backend.ts index 2ba4134..7ea41dd 100755 --- a/scripts/build-backend.ts +++ b/scripts/build-backend.ts @@ -10,6 +10,20 @@ import { packageAndroidJniLibs } from "./lib/android-jni.ts"; import { packageIosFrameworks } from "./lib/ios-frameworks.ts"; import { audit16kAlignment } from "./lib/check-16k-alignment.ts"; +// ------------------------------------------------ +// Mode +// ------------------------------------------------ + +// `--bench` produces the bench-only JS bundle into bench-specific +// sibling paths (`android/src/bench/assets/nodejs-project/` and +// `ios/nodejs-project-bench/`). Native binaries (JNI .so files, +// per-addon xcframeworks, libnode) are NOT re-packaged in bench mode — +// the bench bundle has no native-addon imports, and the production +// build run owns the binaries that `apps/benchmark/` will share at +// install time. Run `npm run backend:build` first if you haven't, then +// `npm run backend:build -- --bench` to produce the bench bundle. +const IS_BENCH = process.argv.slice(2).includes("--bench"); + // ------------------------------------------------ // Paths // ------------------------------------------------ @@ -29,9 +43,22 @@ const ANDROID_MAIN_NODEJS_PROJECT_DIR = join( PROJECT_ROOT, "android/src/main/assets/nodejs-project", ); +// Bench bundle output. Lives under `src/bench/assets/` so AGP's +// per-flavor sourceSet merging picks it up only when the consuming app +// has activated the `bench` productFlavor (see android/build.gradle — +// `apps/benchmark/` activates this; `apps/example/` does not). +const ANDROID_BENCH_NODEJS_PROJECT_DIR = join( + PROJECT_ROOT, + "android/src/bench/assets/nodejs-project", +); const ANDROID_JNILIBS_DIR = join(PROJECT_ROOT, "android/src/main/jniLibs"); const ANDROID_LIBNODE_DIR = join(PROJECT_ROOT, "android/libnode/bin"); const IOS_NODEJS_PROJECT_DIR = join(PROJECT_ROOT, "ios/nodejs-project"); +// Bench-only resource bundle. Picked up by `ComapeoCore.podspec` and +// renamed to `nodejs-project/` in the embedded app bundle when +// `ENV['COMAPEO_BENCH']` is set at pod install (the `with-comapeo-bench` +// config plugin in `apps/benchmark/` sets it). +const IOS_BENCH_NODEJS_PROJECT_DIR = join(PROJECT_ROOT, "ios/nodejs-project-bench"); // One xcframework per native module instance. CocoaPods picks them up // via `vendored_frameworks` in ComapeoCore.podspec; Xcode's standard // Embed & Sign phase places + codesigns them into .app/Frameworks/ @@ -49,6 +76,32 @@ const IOS_FRAMEWORKS_WORK_DIR = join(SCRATCH_DIR, "frameworks"); // Pipeline // ------------------------------------------------ +if (IS_BENCH) { + // Bench mode: rollup only. The bench bundle has no native-addon + // imports, so no prebuilds / JNI / xcframework packaging is needed — + // the production build run already laid those down. We bundle into + // bench-specific output dirs (`android/src/bench/assets/` and + // `ios/nodejs-project-bench/`) which are activated by the `bench` + // Android productFlavor / `ENV['COMAPEO_BENCH']` iOS env var. + await $({ + cwd: BACKEND_SRC_DIR, + stdio: "inherit", + env: { + ...process.env, + BENCH: "1", + OUTPUT_DIR_ANDROID_BENCH: ANDROID_BENCH_NODEJS_PROJECT_DIR, + OUTPUT_DIR_IOS_BENCH: IOS_BENCH_NODEJS_PROJECT_DIR, + }, + })`npm run build`; + + console.log( + `Bench bundle written to:\n ${ANDROID_BENCH_NODEJS_PROJECT_DIR}\n ${IOS_BENCH_NODEJS_PROJECT_DIR}`, + ); + + // Skip the rest of the pipeline — production binaries cover bench too. + process.exit(0); +} + rmSync(SCRATCH_DIR, { force: true, recursive: true }); // 1. Native module ABI is read from the libnode header laid down by diff --git a/scripts/lib/bench-receiver.ts b/scripts/lib/bench-receiver.ts new file mode 100644 index 0000000..7a26b65 --- /dev/null +++ b/scripts/lib/bench-receiver.ts @@ -0,0 +1,182 @@ +#!/usr/bin/env node +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { join } from "node:path"; + +/** + * Localhost HTTP receiver for bench spans posted by `apps/benchmark/`. + * Each span is one JSON object on the request body; spans are appended + * to a per-`runId` NDJSON file under `--out-dir`. After every batch of + * incoming spans the receiver also rewrites a CSV summary keyed by run + * id and payload size — useful for at-a-glance comparison across + * BrowserStack devices when the runner re-uses the same receiver + * instance for the whole device matrix. + * + * Usage: + * + * node scripts/lib/bench-receiver.ts --port 8787 --out-dir ./bench-out + * + * BrowserStack runs reach the receiver via `http://localhost:8787` over + * the BrowserStack Local tunnel. For local dev, point the bench app's + * UI toggle at `http://:8787/spans`. + * + * The receiver is intentionally trivial — no auth, no schema validation + * beyond JSON parse. It binds to `127.0.0.1` only by default; override + * with `--host` if you need it reachable from outside localhost (e.g. + * for a real device on the same LAN — BrowserStack Local takes care of + * tunneling for managed runs). + */ + +type BenchSpan = { + op: "boot" | "rpc"; + name: string; + startTimestamp: number; + durationMs: number; + attrs?: Record; + runId?: string; +}; + +function parseArgs(argv: string[]): { port: number; host: string; outDir: string } { + let port = 8787; + let host = "127.0.0.1"; + let outDir = "./bench-out"; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--port") port = Number(argv[++i]); + else if (a === "--host") host = String(argv[++i]); + else if (a === "--out-dir") outDir = String(argv[++i]); + else if (a === "--help" || a === "-h") { + console.log( + "bench-receiver — collects bench spans posted by apps/benchmark\n\n" + + "Options:\n" + + " --port (default 8787)\n" + + " --host (default 127.0.0.1)\n" + + " --out-dir

(default ./bench-out)", + ); + process.exit(0); + } + } + return { port, host, outDir }; +} + +function safeRunId(raw: unknown, fallback: string): string { + if (typeof raw !== "string") return fallback; + // Restrict to filename-safe chars: prevents traversal via crafted + // run ids in untrusted input. Allow alnum + dash + underscore + dot. + if (!/^[a-zA-Z0-9._-]+$/.test(raw)) return fallback; + return raw; +} + +function percentile(sortedAsc: number[], p: number): number { + if (sortedAsc.length === 0) return Number.NaN; + return sortedAsc[Math.floor((sortedAsc.length - 1) * p)]!; +} + +function rewriteSummary(outDir: string): void { + const rows: string[] = [["runId", "op", "name", "size", "n", "min", "p50", "p95", "p99", "max"].join(",")]; + for (const file of readdirSync(outDir)) { + if (!file.endsWith(".ndjson")) continue; + const runId = file.replace(/\.ndjson$/, ""); + const lines = readFileSync(join(outDir, file), "utf8").split("\n").filter(Boolean); + /** Buckets keyed by `${op}|${name}|${size}`. */ + const buckets = new Map(); + for (const line of lines) { + let span: BenchSpan; + try { + span = JSON.parse(line) as BenchSpan; + } catch { + continue; + } + const sizeAttr = span.attrs && (span.attrs as { bytes?: unknown }).bytes; + const size = typeof sizeAttr === "number" ? String(sizeAttr) : ""; + const key = `${span.op}|${span.name}|${size}`; + const arr = buckets.get(key) ?? []; + arr.push(span.durationMs); + buckets.set(key, arr); + } + for (const [key, durations] of buckets) { + const [op, name, size] = key.split("|"); + const sorted = [...durations].sort((a, b) => a - b); + rows.push( + [ + runId, + op, + name, + size, + sorted.length, + sorted[0]!.toFixed(3), + percentile(sorted, 0.5).toFixed(3), + percentile(sorted, 0.95).toFixed(3), + percentile(sorted, 0.99).toFixed(3), + sorted[sorted.length - 1]!.toFixed(3), + ].join(","), + ); + } + } + writeFileSync(join(outDir, "summary.csv"), rows.join("\n") + "\n"); +} + +function readBody(req: IncomingMessage, limit = 16 * 1024 * 1024): Promise { + return new Promise((resolve, reject) => { + let received = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + received += chunk.length; + if (received > limit) { + reject(new Error("body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function handle(req: IncomingMessage, res: ServerResponse, outDir: string): void { + if (req.method !== "POST") { + res.statusCode = 405; + res.end("POST only"); + return; + } + + readBody(req) + .then((body) => { + let span: BenchSpan; + try { + span = JSON.parse(body) as BenchSpan; + } catch (e) { + res.statusCode = 400; + res.end(`bad json: ${e instanceof Error ? e.message : String(e)}`); + return; + } + const runId = safeRunId(span.runId, "unknown"); + const file = join(outDir, `${runId}.ndjson`); + appendFileSync(file, JSON.stringify(span) + "\n"); + // Cheap to recompute on every span — bench runs are bounded + // (~hundreds of spans per device) and the CSV is the only + // host-side artifact a human reads. + rewriteSummary(outDir); + res.statusCode = 204; + res.end(); + }) + .catch((e: unknown) => { + res.statusCode = 500; + res.end(e instanceof Error ? e.message : String(e)); + }); +} + +const { port, host, outDir } = parseArgs(process.argv.slice(2)); +if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); + +createServer((req, res) => handle(req, res, outDir)).listen(port, host, () => { + console.log(`bench-receiver listening on http://${host}:${port} → ${outDir}/`); +}); diff --git a/src/ComapeoCoreModule.ts b/src/ComapeoCoreModule.ts index b2e48b6..0ca2e7b 100644 --- a/src/ComapeoCoreModule.ts +++ b/src/ComapeoCoreModule.ts @@ -68,9 +68,28 @@ class CoreMessagePort extends EventEmitter { } } -const messagePort = new CoreMessagePort() as unknown as MessagePort; +const corePort = new CoreMessagePort(); +const messagePort = corePort as unknown as MessagePort; export const comapeo: MapeoClientApi = createMapeoClient(messagePort); +/** + * Raw `CoreMessagePort` singleton, exported for the benchmark app + * (`apps/benchmark/`) to bypass the `MapeoClient` request/response + * machinery and speak directly to the bench backend's `BenchRpcServer` + * (which uses a different wire schema — see + * `backend/lib/bench-rpc.js`). Production consumers should use the + * `comapeo` export above; this is a deliberate escape hatch for the + * UDS/RPC bridge benchmark suite (`docs/uds-rpc-bridge-benchmark-plan.md`) + * and ships in the same module surface so the bench app doesn't need + * a private import path. + * + * Note: `createMapeoClient(messagePort)` above already adds a + * `"message"` listener to this port. Bench requests use a different + * `{id, method, params}` shape so the prod RPC machinery treats them + * as unknown frames and silently ignores them. + */ +export const benchMessagePort = corePort; + type StateEvents = { stateChange: (state: ComapeoState, error: ComapeoErrorInfo | null) => void; /** diff --git a/src/index.ts b/src/index.ts index a2aff9a..c243fcf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ // Reexport the native module. On web, it will be resolved to ComapeoCoreModule.web.ts // and on native platforms to ComapeoCoreModule.ts -export { comapeo, state } from "./ComapeoCoreModule"; +export { comapeo, state, benchMessagePort } from "./ComapeoCoreModule"; export * from "./ComapeoCore.types"; From 7f8f04795d645e8770a6f4a4b62ca42fc3a2189b Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Fri, 1 May 2026 14:26:11 +0100 Subject: [PATCH 04/33] fix(bench): Android variant resolution + Expo SDK pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes surfaced when running the bench app end-to-end on a Pixel 7a API 29 emulator: - **Replace Android productFlavor with a project property.** The `bench` / `production` flavor dimension on the lib triggered AGP / Gradle 9 strict variant ambiguity in consuming Expo apps that don't declare matching flavors of their own (apps/expo#18315 etc.): `missingDimensionStrategy` + `matchingFallbacks` weren't enough to disambiguate `benchDebugApiElements` vs. `productionDebugApiElements`. The lib now reads `rootProject.findProperty('comapeoBench')` and swaps `assets.srcDirs` with `=` (assignment, not `srcDirs '<...>'` which AGP treats as additive). Also empties `src/debug/assets` when bench is active so the production debug bundle doesn't overlay bench in debug builds. The `with-comapeo-bench` config plugin switches from `withAppBuildGradle` to `withGradleProperties` and writes `comapeoBench=true` into the consuming app's `android/gradle.properties`. - **Pin Expo modules to SDK 55.** `expo-file-system@19.0.18` and `expo-sharing@14.0.7` (the latest npm versions) are SDK-incompatible with Expo 55 and crashed the JS app at launch with a `NoClassDefFoundError: FilePermissionModuleInterface` autolinking failure. `npx expo install` resolves them to `~55.0.17` / `~55.0.18` which match the rest of the SDK. - **Add `bench-rpc-ios.yaml` Maestro flow.** The Android flow's `clearState: true` triggers a deep-link confirmation dialog on iOS that blocks the rest of the run. The iOS flow drops `clearState` and dismisses the dialog with a guarded `runFlow.when` block. Validation results on Pixel 7a API 29 emulator (debug build, RN-thread RTT in ms, 100 iterations after 10-iteration warmup): size n p50 p95 p99 64B 100 1.65 2.56 7.34 1KB 100 1.68 2.76 4.45 64KB 100 2.48 4.70 6.29 iOS run blocked by a pre-existing lifecycle issue (`AppLifecycleDelegate.applicationDidBecomeActive` doesn't fire under scene-based app lifecycle, so `NodeJSService.start()` is never called) — same code path the example app uses, so this is not a bench regression. Tracked separately. https://claude.ai/code/session_01SC1Sc9AvULHQkQSoQ2SMzJ --- android/build.gradle | 73 +++++++------- apps/benchmark/app.json | 5 +- apps/benchmark/package-lock.json | 31 +++--- apps/benchmark/package.json | 4 +- .../plugins/with-comapeo-bench/index.js | 94 ++++++------------- e2e/.maestro/bench-rpc-ios.yaml | 40 ++++++++ 6 files changed, 126 insertions(+), 121 deletions(-) create mode 100644 e2e/.maestro/bench-rpc-ios.yaml diff --git a/android/build.gradle b/android/build.gradle index bde32ec..2b9d369 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -42,6 +42,24 @@ def comapeoAbiFilters = { return effective }() +// `comapeoBench=true` (set in the consuming app's gradle.properties) +// opts the consuming AAR/APK into the benchmark JS bundle +// (`backend/index.bench.js`, rolled up into +// `src/bench/assets/nodejs-project/`). The bench sourceSet's +// nodejs-project files overlay main's at AGP merge time — bench's +// `index.mjs` replaces production's at the same relative path. Default +// consumers (`apps/example/`, third-party apps) do not set the +// property and never include `src/bench/`. +// +// Implemented as a project property rather than a productFlavor to +// avoid the AGP variant-attribute ambiguity that flavor dimensions +// trigger for Expo apps that don't declare matching flavors of their +// own (AGP 8.x + Gradle 9 strict variant resolution). The +// `with-comapeo-bench` Expo config plugin in `apps/benchmark/` flips +// this property by writing it into the consuming app's +// `android/gradle.properties`. +def comapeoBenchEnabled = (rootProject.findProperty('comapeoBench') ?: project.findProperty('comapeoBench'))?.toString() == 'true' + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") apply from: expoModulesCorePlugin applyKotlinExpoModulesCorePlugin() @@ -88,30 +106,6 @@ android { } } - // Flavor `bench` opts the consuming app into the benchmark JS - // bundle (`backend/index.bench.js`, rolled up into - // `src/bench/assets/nodejs-project/`). Default consumers - // (`apps/example/`, third-party apps) compile the `production` - // flavor and never see `src/bench/`. The bench app - // (`apps/benchmark/`) activates `bench` via - // `missingDimensionStrategy 'comapeo', 'bench'` injected by its - // `with-comapeo-bench` Expo config plugin. - // - // Flavors are intentionally orthogonal to build types: bench works - // in both debug and release so real-app perf-feel can be measured - // under R8/ProGuard. The bench sourceSet only adds assets — no - // code, no native libs — so minification has nothing to strip. - flavorDimensions "comapeo" - productFlavors { - production { - dimension "comapeo" - isDefault true - } - bench { - dimension "comapeo" - } - } - lintOptions { abortOnError false } @@ -123,21 +117,24 @@ android { // native module instance × ABI). Both directories ship into the // same APK `lib//` segment. jniLibs.srcDirs 'libnode/bin/', 'src/main/jniLibs/' - assets { - srcDirs 'src/main/assets' - } + // When `comapeoBench=true`, swap `src/main/assets` for + // `src/bench/assets`. Assigning rather than calling — + // `srcDirs '<...>'` is additive (AGP keeps the default + // `src/main/assets`); assignment replaces. + // + // AGP rejects duplicate relative paths across srcDirs, so + // we replace rather than overlay. + assets.srcDirs = comapeoBenchEnabled + ? ['src/bench/assets'] + : ['src/main/assets'] } - // When the `bench` flavor is selected, AGP merges this sourceSet - // on top of `main`. `index.mjs` (the rolled-up bench bundle) - // overlays its production counterpart at the same relative path - // (`nodejs-project/index.mjs`); the rest of `nodejs-project/` - // (drizzle migrations etc.) is inherited from `main` but never - // imported by `index.bench.js` — bench-bundle bloat without - // correctness impact. - bench { - assets { - srcDirs 'src/bench/assets' - } + // The default `debug` sourceSet picks up `src/debug/assets` + // (where `scripts/build-backend.ts` writes the unminified + // production bundle for debug builds). When bench is active, + // empty it out so the prod debug bundle doesn't overlay our + // bench bundle and end up shipping in the bench APK. + debug { + assets.srcDirs = comapeoBenchEnabled ? [] : ['src/debug/assets'] } } externalNativeBuild { diff --git a/apps/benchmark/app.json b/apps/benchmark/app.json index ab7f865..0ac70df 100644 --- a/apps/benchmark/app.json +++ b/apps/benchmark/app.json @@ -22,6 +22,9 @@ }, "package": "com.comapeo.core.benchmark" }, - "plugins": ["./plugins/with-comapeo-bench"] + "plugins": [ + "./plugins/with-comapeo-bench", + "expo-sharing" + ] } } diff --git a/apps/benchmark/package-lock.json b/apps/benchmark/package-lock.json index 59ccb45..c40ff76 100644 --- a/apps/benchmark/package-lock.json +++ b/apps/benchmark/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "dependencies": { "expo": "55.0.17", - "expo-file-system": "19.0.18", - "expo-sharing": "14.0.7", + "expo-file-system": "~55.0.17", + "expo-sharing": "~55.0.18", "react": "19.2.5", "react-native": "0.83.6", "react-native-safe-area-context": "5.6.2" @@ -3789,9 +3789,9 @@ } }, "node_modules/expo-file-system": { - "version": "19.0.18", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.18.tgz", - "integrity": "sha512-/IN4H2HHoRFiuPTs0ty8CwoDxT0GVo2tYcO4BxNniSZ4FYjtPiNWPqLvq7RKV++EHllHox/jnV/rkTzwwNERxA==", + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.17.tgz", + "integrity": "sha512-d27K1cagUOt2BwxwPka9KW8Znu5kN1tnairozCzzCRZviZFtWnBxwFuJ3KU6MAbav/9UhSMkp5Ve/oZ+SR0UgQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -3866,21 +3866,18 @@ } }, "node_modules/expo-sharing": { - "version": "14.0.7", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.7.tgz", - "integrity": "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "55.0.17", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.17.tgz", - "integrity": "sha512-d27K1cagUOt2BwxwPka9KW8Znu5kN1tnairozCzzCRZviZFtWnBxwFuJ3KU6MAbav/9UhSMkp5Ve/oZ+SR0UgQ==", + "version": "55.0.18", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-55.0.18.tgz", + "integrity": "sha512-Tqy4LXRLw/UEg5mT7BKhx8y4ReNz8fVldvhHJV5cesH3kRgEerHkYxVwid2vd7v34KnNp0RH1OqUyDlzZTQ9AQ==", "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^55.0.8", + "@expo/config-types": "^55.0.5", + "@expo/plist": "^0.5.2" + }, "peerDependencies": { "expo": "*", + "react": "*", "react-native": "*" } }, diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json index 8d28118..332c874 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "expo": "55.0.17", - "expo-file-system": "19.0.18", - "expo-sharing": "14.0.7", + "expo-file-system": "~55.0.17", + "expo-sharing": "~55.0.18", "react": "19.2.5", "react-native": "0.83.6", "react-native-safe-area-context": "5.6.2" diff --git a/apps/benchmark/plugins/with-comapeo-bench/index.js b/apps/benchmark/plugins/with-comapeo-bench/index.js index f116e68..1a3461d 100644 --- a/apps/benchmark/plugins/with-comapeo-bench/index.js +++ b/apps/benchmark/plugins/with-comapeo-bench/index.js @@ -1,30 +1,26 @@ const { - withAppBuildGradle, + withGradleProperties, withPodfile, withXcodeProject, } = require('@expo/config-plugins'); -const { - mergeContents, -} = require('@expo/config-plugins/build/utils/generateCode'); /** - * Activates the `bench` Android productFlavor + the iOS bench resource - * opt-in for this Expo app on every `expo prebuild`. The bench app - * (`apps/benchmark/`) does not check in `android/` or `ios/`, so this - * plugin is the single source of truth for the native wiring; production - * consumers (`apps/example/`, third parties) never apply it and never - * see any bench artefacts in their APK / IPA. + * Opts this Expo app into the benchmark JS bundle on every + * `expo prebuild`. The bench app (`apps/benchmark/`) does not check in + * `android/` or `ios/`, so this plugin is the single source of truth + * for the native wiring; production consumers (`apps/example/`, third + * parties) never apply it and never see any bench artefacts. * * Three idempotent mutations: * - * - `withAppBuildGradle` injects, into `android/app/build.gradle`: - * defaultConfig { missingDimensionStrategy 'comapeo', 'bench' } - * buildTypes.{debug,release} { matchingFallbacks = ['production'] } - * so AGP resolves the consuming app's build types against the - * module's `bench` flavor, and falls back to `production` for - * anything that doesn't have a bench-specific configuration. Heads - * off the known release-variant footgun (expo/expo#18315, #16686, - * #23266). + * - `withGradleProperties` writes `comapeoBench=true` into the + * consuming app's `android/gradle.properties`. The module's + * `android/build.gradle` reads `rootProject.findProperty('comapeoBench')` + * and, when set, overlays `src/bench/assets/` (containing the bench + * `nodejs-project/index.mjs`) onto its main asset srcDirs. The + * property mechanism is intentionally chosen over a productFlavor + * to avoid AGP / Gradle 9 variant-attribute ambiguity for Expo apps + * that don't declare matching flavors of their own. * * - `withPodfile` prepends `ENV['COMAPEO_BENCH'] = '1'` to the top of * `ios/Podfile` so it's set BEFORE Expo autolinking evaluates @@ -39,59 +35,31 @@ const { * bench bundle masquerade as the production bundle on disk * without touching the loader. * - * Each mutation guards on a sentinel-tag include-check so re-runs of - * `expo prebuild` are idempotent — string-based mods compose poorly - * without this and the Expo docs explicitly warn about it - * (https://docs.expo.dev/config-plugins/mods/). + * Each mutation is idempotent so re-runs of `expo prebuild` don't + * accumulate duplicate insertions. */ function withComapeoBench(config) { - config = withBenchAndroidGradle(config); + config = withBenchGradleProperties(config); config = withBenchPodfile(config); config = withBenchIosRenameScript(config); return config; } -const ANDROID_DEFAULT_CONFIG_INSERT = - " missingDimensionStrategy 'comapeo', 'bench'"; - -const ANDROID_BUILD_TYPES_INSERT = [ - ' debug {', - " matchingFallbacks = ['production']", - ' }', - ' release {', - " matchingFallbacks = ['production']", - ' }', -].join('\n'); - -function withBenchAndroidGradle(config) { - return withAppBuildGradle(config, (cfg) => { - if (cfg.modResults.language !== 'groovy') { - console.warn( - 'with-comapeo-bench: app/build.gradle is not groovy; skipping Android wiring', - ); - return cfg; +function withBenchGradleProperties(config) { + return withGradleProperties(config, (cfg) => { + const props = cfg.modResults; + const existing = props.find( + (p) => p.type === 'property' && p.key === 'comapeoBench', + ); + if (existing) { + existing.value = 'true'; + } else { + props.push({ + type: 'comment', + value: ' bench bundle opt-in — read by android/build.gradle of @comapeo/core-react-native', + }); + props.push({ type: 'property', key: 'comapeoBench', value: 'true' }); } - let contents = cfg.modResults.contents; - - contents = mergeContents({ - tag: 'with-comapeo-bench:missing-dimension', - src: contents, - newSrc: ANDROID_DEFAULT_CONFIG_INSERT, - anchor: /defaultConfig\s*\{/, - offset: 1, - comment: '//', - }).contents; - - contents = mergeContents({ - tag: 'with-comapeo-bench:matching-fallbacks', - src: contents, - newSrc: ANDROID_BUILD_TYPES_INSERT, - anchor: /buildTypes\s*\{/, - offset: 1, - comment: '//', - }).contents; - - cfg.modResults.contents = contents; return cfg; }); } diff --git a/e2e/.maestro/bench-rpc-ios.yaml b/e2e/.maestro/bench-rpc-ios.yaml new file mode 100644 index 0000000..4e50c28 --- /dev/null +++ b/e2e/.maestro/bench-rpc-ios.yaml @@ -0,0 +1,40 @@ +appId: com.comapeo.core.benchmark +--- +# iOS-flavoured variant of bench-rpc.yaml. Differs in two ways: +# +# - Does NOT use `clearState: true` on launch — that triggers the +# iOS dev-client URL scheme dialog ("Open in core-react-native- +# benchmark?") which blocks the rest of the flow. +# - Tolerates the same dialog appearing during launch by tapping +# "Open" if it shows up before assertions. + +- launchApp + +- runFlow: + when: + visible: + text: "Open" + commands: + - tapOn: + text: "Open" + +- extendedWaitUntil: + visible: + id: "service-state" + text: "STARTED" + timeout: 60000 + +- tapOn: + id: "send-button" + +- extendedWaitUntil: + visible: + id: "benchmark-result" + timeout: 180000 + +- assertVisible: + id: "benchmark-result" +- assertVisible: + text: "p50" +- assertVisible: + text: "p99" From e7b9a520e26747eba8c866309930fff4e03bf6ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 17:10:35 +0000 Subject: [PATCH 05/33] fix(bench): iOS resource ordering + shutdown race + Copilot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCKER (iOS rename ordering): the previous design added an Xcode Run Script build phase via the config plugin's `withXcodeProject`, but CocoaPods 1.x doesn't reliably position user script phases after `[CP] Copy Pods Resources` — the rename ran before the bench files were on disk and silently no-op'd, leaving bench builds with no `.app/nodejs-project/` and a non-bootable runtime. Switch to pod-install-time staging in `ComapeoCore.podspec`: when COMAPEO_BENCH=1 the podspec stages a copy of `nodejs-project-bench/` to `.bench-staging/nodejs-project/` and adds it to `s.resources` ALONGSIDE the production `nodejs-project/`. CocoaPods rsyncs both into `.app/nodejs-project/` in declaration order, with the bench overlay landing on top — no script phase, no ordering footgun. MAJOR (iOS resource fallback): previous design REPLACED `nodejs-project` with `nodejs-project-bench`, so any rename failure left the app non-bootable. New shape ships both: bench overlays prod, but if the bench bundle is missing (forgot to run `--bench`) the prod bundle remains as fallback. MAJOR (shutdown race): an in-flight `SocketMessagePort.postMessage` landing in streamx's deferred microtask after the AF_UNIX socket has been ended raises `ERR_STREAM_WRITE_AFTER_END` past every listener. The race is benign (the message was already destined for a torn-down peer). Add a state-check + underlying-socket error listener in `message-port.js`, and a targeted `uncaughtException` / `unhandledRejection` filter in `index.bench.js` that swallows the specific code while a graceful shutdown is in progress. Smoke test now exits 0 with all spans + responses recorded; previous run hit `fatal during runtime` and exit 1. Copilot review feedback addressed: - App.tsx: drop unused `useMemo`; replace nearest-rank percentile with linear-interpolation (matches PR description); add 30s per-request timeout + pending-map cleanup so a lost frame doesn't hang the run; update stale "READY" comment to "STARTED". - bench-receiver.ts: same linear-interpolation fix so on-device and host-side numbers agree. - Stale productFlavor / withXcodeProject references in App.tsx, scripts/build-backend.ts, backend/rollup.config.ts, and the plan doc updated to describe the actual `comapeoBench` Gradle property + podspec staging mechanism. https://claude.ai/code/session_01SC1Sc9AvULHQkQSoQ2SMzJ --- .gitignore | 10 +- apps/benchmark/App.tsx | 63 ++++++--- .../plugins/with-comapeo-bench/index.js | 116 +++------------- backend/index.bench.js | 38 ++++++ backend/lib/message-port.js | 26 ++++ backend/rollup.config.ts | 10 +- docs/uds-rpc-bridge-benchmark-plan.md | 124 +++++++++++------- ios/ComapeoCore.podspec | 51 ++++--- scripts/build-backend.ts | 16 ++- scripts/lib/bench-receiver.ts | 10 +- 10 files changed, 268 insertions(+), 196 deletions(-) diff --git a/.gitignore b/.gitignore index be51a2b..e53bb9a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,8 +71,13 @@ ios/.swiftpm/ # xcframework into ios/Frameworks/. # * Bench-only sibling outputs (npm run backend:build -- --bench) # land in `android/src/bench/assets/` and `ios/nodejs-project-bench/`; -# activated by the `bench` Android productFlavor and the -# `ENV['COMAPEO_BENCH']` iOS env var respectively. +# activated on Android by the `comapeoBench` Gradle property +# (set in the consuming app's `gradle.properties`), and on iOS by +# the `ENV['COMAPEO_BENCH']` env var read by the podspec at pod +# install time. The podspec also stages a copy of +# `ios/nodejs-project-bench/` to `ios/.bench-staging/nodejs-project/` +# so CocoaPods can rsync it onto the production bundle in the app +# resources without an Xcode Run Script phase. backend/dist nodejs-assets android/src/debug/assets/ @@ -81,6 +86,7 @@ android/src/main/jniLibs/ android/src/bench/assets/ ios/nodejs-project/ ios/nodejs-project-bench/ +ios/.bench-staging/ ios/Frameworks/ # output diff --git a/apps/benchmark/App.tsx b/apps/benchmark/App.tsx index 79c58e7..4fd2f83 100644 --- a/apps/benchmark/App.tsx +++ b/apps/benchmark/App.tsx @@ -5,7 +5,7 @@ import { } from "@comapeo/core-react-native"; import { Directory, File, Paths } from "expo-file-system"; import * as Sharing from "expo-sharing"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Pressable, ScrollView, @@ -20,13 +20,13 @@ import { SafeAreaView } from "react-native-safe-area-context"; /** * Benchmark app entry. Drives the bench RPC bridge through the same * RN→native→Node UDS path as the production module, but talks to the - * stripped `backend/index.bench.js` (via the `bench` Android - * productFlavor / `ENV['COMAPEO_BENCH']` iOS opt-in) — so timings - * isolate the framing / IPC / JSON-RPC bridge from `@comapeo/core` init - * noise. See `docs/uds-rpc-bridge-benchmark-plan.md`. + * stripped `backend/index.bench.js` (via the `comapeoBench=true` + * Gradle property on Android / `ENV['COMAPEO_BENCH']` iOS opt-in) — + * so timings isolate the framing / IPC / JSON-RPC bridge from + * `@comapeo/core` init noise. See `docs/uds-rpc-bridge-benchmark-plan.md`. * * UI surface: - * - boot status (state observer): waits for "READY" before enabling + * - boot status (state observer): waits for "STARTED" before enabling * the run button. * - payload-size selector: subset of {64B, 1KB, 64KB, 1MB} per run. * - "Run benchmark" button (testID="send-button"): runs warmup + @@ -45,6 +45,7 @@ const PAYLOAD_SIZES = [64, 1024, 65536, 1048576] as const; const DEFAULT_SELECTED: ReadonlyArray = [64, 1024, 65536]; const WARMUP_ITERATIONS = 10; const STEADY_ITERATIONS = 100; +const REQUEST_TIMEOUT_MS = 30_000; const RECEIVER_DEFAULT_URL = "http://localhost:8787/spans"; type BenchSpan = { @@ -73,9 +74,14 @@ type RunReport = { spanFile: string; }; +type BenchResponse = { result?: unknown; error?: { message: string } }; + class BenchClient { private nextId = 0; - private pending = new Map void>(); + private pending = new Map< + string, + { resolve: (r: BenchResponse) => void; timer: ReturnType } + >(); private listenerInstalled = false; ensureListener() { @@ -90,19 +96,35 @@ class BenchClient { return; } const m = msg as { id: string; result?: unknown; error?: { message: string } }; - const cb = this.pending.get(m.id); - if (cb) { + const entry = this.pending.get(m.id); + if (entry) { + clearTimeout(entry.timer); this.pending.delete(m.id); - cb({ result: m.result, error: m.error }); + entry.resolve({ result: m.result, error: m.error }); } }); } - request(method: string, params?: unknown): Promise<{ result?: unknown; error?: { message: string } }> { + request(method: string, params?: unknown, timeoutMs = REQUEST_TIMEOUT_MS): Promise { this.ensureListener(); const id = `bench-${this.nextId++}`; return new Promise((resolve) => { - this.pending.set(id, resolve); + // Per-request timeout so a lost frame / disconnected backend + // doesn't hang the run forever and leak the pending entry. + // Caller surfaces `error.message === "timeout"` through the same + // path as a backend-emitted error. + const timer = setTimeout(() => { + if (this.pending.delete(id)) { + resolve({ error: { message: `bench rpc timeout after ${timeoutMs}ms (method=${method})` } }); + } + }, timeoutMs); + // `unref` so the timer doesn't keep the JS runtime alive on its + // own — RN's JS thread doesn't actually exit, but it's a good + // habit and avoids surprises if this code is ported back to Node. + if (typeof (timer as unknown as { unref?: () => void }).unref === "function") { + (timer as unknown as { unref: () => void }).unref(); + } + this.pending.set(id, { resolve, timer }); benchMessagePort.postMessage({ id, method, params } as never); }); } @@ -110,12 +132,17 @@ class BenchClient { function percentile(sortedAsc: number[], p: number): number { if (sortedAsc.length === 0) return Number.NaN; - // Linear interpolation between closest ranks. For our sample sizes - // (~100), `Math.floor((n-1) * p)` is good enough and avoids the - // off-by-one trap of `Math.floor(n * p)` (which would index past the - // end at p=1). - const idx = Math.floor((sortedAsc.length - 1) * p); - return sortedAsc[idx]!; + // Linear interpolation between closest ranks (a.k.a. the "C=1" / + // NumPy default percentile method). For p=0.5 over 100 samples this + // averages indices 49 and 50; nearest-rank would return index 49 + // alone, biasing low for small samples. Bench p99 is the usual + // outlier — `n*p=99` lands exactly on the 99th sample so weight=0. + const position = (sortedAsc.length - 1) * p; + const lower = Math.floor(position); + const upper = Math.ceil(position); + if (lower === upper) return sortedAsc[lower]!; + const weight = position - lower; + return sortedAsc[lower]! + (sortedAsc[upper]! - sortedAsc[lower]!) * weight; } function summarise(samples: number[], sizeBytes: number): SizeStats { diff --git a/apps/benchmark/plugins/with-comapeo-bench/index.js b/apps/benchmark/plugins/with-comapeo-bench/index.js index 1a3461d..77b0cc8 100644 --- a/apps/benchmark/plugins/with-comapeo-bench/index.js +++ b/apps/benchmark/plugins/with-comapeo-bench/index.js @@ -1,7 +1,6 @@ const { withGradleProperties, withPodfile, - withXcodeProject, } = require('@expo/config-plugins'); /** @@ -11,29 +10,29 @@ const { * for the native wiring; production consumers (`apps/example/`, third * parties) never apply it and never see any bench artefacts. * - * Three idempotent mutations: + * Two idempotent mutations: * * - `withGradleProperties` writes `comapeoBench=true` into the * consuming app's `android/gradle.properties`. The module's * `android/build.gradle` reads `rootProject.findProperty('comapeoBench')` - * and, when set, overlays `src/bench/assets/` (containing the bench - * `nodejs-project/index.mjs`) onto its main asset srcDirs. The - * property mechanism is intentionally chosen over a productFlavor - * to avoid AGP / Gradle 9 variant-attribute ambiguity for Expo apps - * that don't declare matching flavors of their own. + * and, when set, swaps `assets.srcDirs` to `src/bench/assets/` + * (containing the bench `nodejs-project/index.mjs`). The property + * mechanism is intentionally chosen over a productFlavor to avoid + * AGP / Gradle 9 variant-attribute ambiguity for Expo apps that + * don't declare matching flavors of their own. * * - `withPodfile` prepends `ENV['COMAPEO_BENCH'] = '1'` to the top of * `ios/Podfile` so it's set BEFORE Expo autolinking evaluates - * `ComapeoCore.podspec`. The podspec reads that env var and swaps - * `nodejs-project-bench` in for `nodejs-project` in `s.resources`. - * - * - `withXcodeProject` adds a Run Script build phase to the iOS app - * target that renames the embedded `nodejs-project-bench/` directory - * to `nodejs-project/` after Copy Bundle Resources. The native - * loader (`NodeJSService.swift`) reads from a fixed - * `.app/nodejs-project/` path; this rename is what lets the - * bench bundle masquerade as the production bundle on disk - * without touching the loader. + * `ComapeoCore.podspec`. The podspec reads that env var and stages + * the bench bundle (`nodejs-project-bench/`) to + * `.bench-staging/nodejs-project/`, then declares both the staged + * bench bundle and the production `nodejs-project/` in + * `s.resources` — CocoaPods' `[CP] Copy Pods Resources` rsyncs + * them in declaration order so the bench overlay lands on top. + * No Xcode Run Script build phase is needed (the previous + * iteration hit a CocoaPods 1.x ordering footgun where user + * script phases can't reliably be positioned after + * `[CP] Copy Pods Resources`). * * Each mutation is idempotent so re-runs of `expo prebuild` don't * accumulate duplicate insertions. @@ -41,7 +40,6 @@ const { function withComapeoBench(config) { config = withBenchGradleProperties(config); config = withBenchPodfile(config); - config = withBenchIosRenameScript(config); return config; } @@ -85,86 +83,4 @@ function withBenchPodfile(config) { }); } -const RENAME_SCRIPT_NAME = 'with-comapeo-bench: rename nodejs-project-bench → nodejs-project'; -const RENAME_SCRIPT_BODY = [ - '#!/bin/sh', - '# @generated by with-comapeo-bench (apps/benchmark/plugins).', - '# CocoaPods copies the bench bundle to .app/nodejs-project-bench/.', - '# The native loader reads from .app/nodejs-project/ (a path that', - '# does not change between flavors — see ios/ComapeoCore.podspec). Move', - '# the bench bundle into place after Copy Bundle Resources so the', - '# loader picks up bench code without changing the lookup path.', - 'set -e', - 'TARGET_DIR="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"', - 'if [ -d "${TARGET_DIR}/nodejs-project-bench" ]; then', - ' rm -rf "${TARGET_DIR}/nodejs-project"', - ' mv "${TARGET_DIR}/nodejs-project-bench" "${TARGET_DIR}/nodejs-project"', - 'fi', -].join('\n'); - -function withBenchIosRenameScript(config) { - return withXcodeProject(config, (cfg) => { - const project = cfg.modResults; - const targets = project.pbxNativeTargetSection(); - if (!targets) return cfg; - - let appTargetUuid; - for (const [uuid, target] of Object.entries(targets)) { - // Skip pbxproj comment entries (uuid keys ending with `_comment`). - if (uuid.includes('_comment')) continue; - // Application target's `productType` is com.apple.product-type.application. - const productType = target.productType?.replace(/['"]/g, ''); - if (productType === 'com.apple.product-type.application') { - appTargetUuid = uuid; - break; - } - } - - if (!appTargetUuid) { - console.warn( - 'with-comapeo-bench: no application target found in pbxproj; skipping rename script', - ); - return cfg; - } - - // Idempotency: if a Run Script phase with our exact name already - // exists anywhere in the pbxproj, leave it alone. (xcode-node - // exposes script phases as a flat dict keyed by UUID; per-target - // filtering would require walking `target.buildPhases` references, - // but a name match is sufficient because the name is plugin- - // generated and unique.) - const allScriptPhases = project.hash?.project?.objects?.PBXShellScriptBuildPhase; - if (allScriptPhases && typeof allScriptPhases === 'object') { - for (const [key, phase] of Object.entries(allScriptPhases)) { - if (key.endsWith('_comment')) continue; - if ( - phase && - typeof phase === 'object' && - phase.name && - String(phase.name).replace(/^"|"$/g, '') === RENAME_SCRIPT_NAME - ) { - return cfg; - } - } - } - - project.addBuildPhase( - [], - 'PBXShellScriptBuildPhase', - RENAME_SCRIPT_NAME, - appTargetUuid, - { - shellPath: '/bin/sh', - shellScript: RENAME_SCRIPT_BODY, - // Run after Copy Bundle Resources so the bench dir is on disk - // when the rename runs. - inputPaths: [], - outputPaths: [], - }, - ); - - return cfg; - }); -} - module.exports = withComapeoBench; diff --git a/backend/index.bench.js b/backend/index.bench.js index 1e6acbc..6d999e7 100644 --- a/backend/index.bench.js +++ b/backend/index.bench.js @@ -46,6 +46,13 @@ const sink = createSinkFromArg( /** @type {BenchRpcServer | undefined} */ let benchRpcServer; +/** + * Set true once a graceful shutdown has been initiated. Used by the + * `uncaughtException` filter below to distinguish a shutdown-race + * write-after-end (benign — the peer is going away) from a real + * runtime fault that should tear the process down. + */ +let isShuttingDown = false; /** @type {(rootKey: unknown) => void} */ let resolveInit; @@ -81,6 +88,7 @@ const controlIpcServer = new SimpleRpcServer({ resolveInit(message.rootKey ?? null); }, shutdown: async () => { + isShuttingDown = true; // Match production's shutdown frame ordering so native lifecycle // detection (graceful exit vs. crash) keeps working unchanged. controlIpcServer.broadcast({ type: "stopping" }); @@ -123,10 +131,40 @@ async function handleFatal(phase, error) { process.exit(1); } +/** + * Filter for the streamx-microtask shutdown race described above. + * + * Returns true if `e` is an `ERR_STREAM_WRITE_AFTER_END` thrown while + * a graceful shutdown is in progress — benign by definition (the + * message was already destined for a peer being torn down). Returned + * to both `uncaughtException` and `unhandledRejection` because Node's + * internal writable surfaces this error via either path depending on + * which microtask boundary it crosses. + * + * @param {unknown} e + */ +function isBenignShutdownWriteAfterEnd(e) { + return ( + isShuttingDown && + !!e && + typeof e === "object" && + "code" in e && + /** @type {NodeJS.ErrnoException} */ (e).code === "ERR_STREAM_WRITE_AFTER_END" + ); +} + process.on("uncaughtException", (error) => { + if (isBenignShutdownWriteAfterEnd(error)) { + console.warn("Bench: uncaught write-after-end during shutdown, ignored"); + return; + } handleFatal("runtime", error); }); process.on("unhandledRejection", (reason) => { + if (isBenignShutdownWriteAfterEnd(reason)) { + console.warn("Bench: unhandled-rejection write-after-end during shutdown, ignored"); + return; + } handleFatal("runtime", reason); }); diff --git a/backend/lib/message-port.js b/backend/lib/message-port.js index d77621d..26a6267 100644 --- a/backend/lib/message-port.js +++ b/backend/lib/message-port.js @@ -60,12 +60,38 @@ export class SocketMessagePort extends TypedEmitter { // TODO: Emit error, handle in consumer console.error("FramedStream error", error); }); + // The underlying AF_UNIX socket emits its own 'error' event for + // events the framed-stream wrapper doesn't surface — most + // importantly `ERR_STREAM_WRITE_AFTER_END` when a producer attempts + // to post during the half-closed shutdown window. Without a + // listener here, a stray write during graceful teardown bubbles to + // `uncaughtException` and the process exits 1, even though the + // shutdown was orderly. Log + swallow: the writer side already + // detects close via `state === "closed"` (see `postMessage`) so a + // socket-level error post-close has no further consequence. + socket.on("error", (error) => { + console.warn( + "SocketMessagePort: underlying socket error", + /** @type {NodeJS.ErrnoException} */ (error).code ?? error.message, + ); + }); } /** * @param {JsonValue} message */ postMessage(message) { + // Drop writes after close. AF_UNIX writes against an ended socket + // raise `ERR_STREAM_WRITE_AFTER_END`; the natural shutdown race + // (in-flight RPC response posted while the server is closing its + // sockets) lands here. NOTE: this guard handles writes posted + // AFTER close completes. Writes posted BEFORE close completes but + // executed (via streamx's nextTick microtask) after the underlying + // socket has ended end up throwing past this check; the host + // process's `uncaughtException` handler must filter the resulting + // `ERR_STREAM_WRITE_AFTER_END` during shutdown — see + // `backend/index.bench.js`. + if (this.#state === "closed") return; this.#framedStream.write(Buffer.from(JSON.stringify(message))); } diff --git a/backend/rollup.config.ts b/backend/rollup.config.ts index fadda75..7b33d7b 100644 --- a/backend/rollup.config.ts +++ b/backend/rollup.config.ts @@ -41,10 +41,14 @@ const IOS_OUT = process.env.OUTPUT_DIR_IOS ?? path.join(__dirname, "dist/ios"); /** * `BENCH=1` switches this config to emit the bench-only bundle * (`index.bench.js`) into the bench-specific output trees: - * - `android/src/bench/assets/nodejs-project/` (overlaid by the - * `bench` Android productFlavor — see android/build.gradle) + * - `android/src/bench/assets/nodejs-project/` (selected by the + * module's `android/build.gradle` when the `comapeoBench` Gradle + * property is set — this replaced the earlier productFlavor + * approach to dodge AGP variant ambiguity) * - `ios/nodejs-project-bench/` (picked up by `ComapeoCore.podspec` - * iff `ENV['COMAPEO_BENCH']` is set at pod install time) + * iff `ENV['COMAPEO_BENCH']` is set at pod install time; the + * podspec also stages a copy at `ios/.bench-staging/nodejs-project/` + * so CocoaPods rsyncs it on top of the production bundle) * * Default (no env var) is unchanged: production `index.js` to the * existing main/debug/iOS paths. diff --git a/docs/uds-rpc-bridge-benchmark-plan.md b/docs/uds-rpc-bridge-benchmark-plan.md index f32193f..cb756db 100644 --- a/docs/uds-rpc-bridge-benchmark-plan.md +++ b/docs/uds-rpc-bridge-benchmark-plan.md @@ -95,29 +95,49 @@ artefacts in their APK or IPA. Three independent guards enforce this: `./plugins/with-comapeo-bench`. `expo prebuild` regenerates the `android/` and `ios/` directories on demand; nothing under those paths is checked into git for `apps/benchmark/`. - - Android: the plugin uses `withAppBuildGradle` to append - `flavorDimensions += "comapeo"` and - `missingDimensionStrategy 'comapeo', 'bench'` to the bench app's - `android/app/build.gradle` `defaultConfig`. The module's own - `android/build.gradle` declares the `bench` flavor + sourceSet; - consumers that don't activate it (`apps/example/`, third-party - apps) get the default flavor and never see `src/bench/`. + - Android: the plugin uses `withGradleProperties` to write + `comapeoBench=true` into the consuming app's + `android/gradle.properties`. The module's own `android/build.gradle` + reads `rootProject.findProperty('comapeoBench')` and, when set, + replaces `assets.srcDirs` with `src/bench/assets/` (and zeroes out + `src/debug/assets/` so the production debug overlay doesn't shadow + the bench bundle in debug builds). Consumers that don't set the + property (`apps/example/`, third-party apps) keep the default + `src/main/assets/` and never see `src/bench/`. The earlier design + used a `bench` productFlavor + `missingDimensionStrategy`, but that + hit AGP / Gradle 9 strict variant resolution ambiguity for Expo + apps that don't declare matching flavors of their own (expo/expo + #18315 et al.); a project property dodges the variant-attribute + graph entirely. - iOS: the plugin uses `withPodfile` (the canonical `@expo/config-plugins` mod for Podfile string edits) to prepend `ENV['COMAPEO_BENCH'] = '1'` to the regenerated `ios/Podfile` above the autolinking block, so the env var is set before pod install reads `ComapeoCore.podspec`. The module's - `ComapeoCore.podspec` reads that env var at `pod install` time and - conditionally appends `nodejs-project-bench` to `s.resources`. - With no env var, the podspec ships only the production - `nodejs-project/`. Each mutation guards with an `includes(sentinel)` - check so re-runs of `expo prebuild` are idempotent (string-based - mods compose poorly without this — Expo docs explicitly warn about - it). `withDangerousMod` is reserved as an escape hatch and is not - needed here. Note: `expo-build-properties.ios.extraPods` cannot - express a subspec opt-in (no `:subspecs` field) and only appends — - it cannot override the autolinked `pod 'ComapeoCore'` entry — which - is why the env-var-driven podspec is the right shape. + `ComapeoCore.podspec` reads that env var at `pod install` time + and, when set, stages a copy of `nodejs-project-bench/` to + `.bench-staging/nodejs-project/` and adds it to `s.resources` + **alongside** the production `nodejs-project/`. CocoaPods' + `[CP] Copy Pods Resources` rsyncs both into the app bundle in + declaration order, with the same destination basename + (`.app/nodejs-project/`); the bench overlay's `index.mjs` + replaces production's at the same path. If the bench bundle is + missing (forgot to run `--bench`), staging is skipped and only + the production bundle ships — the bench app boots production + instead of crashing on a missing resource. With no env var, the + podspec ships exactly today's `nodejs-project/`, byte-identical. + An earlier iteration tried to do the rename via an Xcode Run + Script build phase (added by the plugin via `withXcodeProject`) + but CocoaPods 1.x doesn't reliably position user script phases + after `[CP] Copy Pods Resources`, so the rename ran before the + bench files were on disk and silently no-op'd; the staging + approach sidesteps the ordering problem entirely. The Podfile + mutation guards with an `includes(sentinel)` check so re-runs of + `expo prebuild` are idempotent. Note: + `expo-build-properties.ios.extraPods` cannot express a subspec + opt-in (no `:subspecs` field) and only appends — it cannot + override the autolinked `pod 'ComapeoCore'` entry — which is why + the env-var-driven podspec is the right shape. 3. **Publish-time exclusion.** `package.json`'s `files` array does not list `android/src/bench/` or `ios/nodejs-project-bench/`, so even if a developer accidentally runs `--bench` before publishing, those paths @@ -137,17 +157,20 @@ bench app, whose `app.json` lists the plugin, links the bench bundle. sandbox. To avoid changing the native loader, the bench variant substitutes the bundle in place: -- Android: AGP's per-variant asset overlay replaces files in - `assets/nodejs-project/` with the bench versions when the `bench` - flavor is active, because the bench sourceSet writes the bundle to the - same relative path (`nodejs-project/`) under its own `src/bench/assets` - root. Production builds never see `src/bench/`. -- iOS: the podspec packages `nodejs-project-bench/` as a separate - resource bundle when `ENV['COMAPEO_BENCH']` is set. A small build - phase (added by the `with-comapeo-bench` plugin via - `withXcodeProject`) renames it to `nodejs-project/` in the embedded - app bundle at copy time. The default build ships only the production - `nodejs-project/`. +- Android: when `comapeoBench=true`, the module's `android/build.gradle` + reassigns `sourceSets.main.assets.srcDirs` to `['src/bench/assets']` + (and empties `sourceSets.debug.assets.srcDirs` so the production + debug overlay doesn't shadow it). The bench bundle's relative path + inside its sourceSet is `nodejs-project/`, the same as production, + so the AAR ships exactly one `nodejs-project/` — bench's. Default + consumer (no property) keeps the production `src/main/assets`. +- iOS: the podspec stages `nodejs-project-bench/` to + `.bench-staging/nodejs-project/` at pod install time when + `ENV['COMAPEO_BENCH']` is set, and lists both `nodejs-project` and + `.bench-staging/nodejs-project` in `s.resources`. CocoaPods rsyncs + them in declaration order to `.app/nodejs-project/`; the bench + overlay's `index.mjs` replaces production's. The default build + (no env var) ships only the production `nodejs-project/`. Both variants leave the existing `NodeJSService.swift` and Android Node launcher unchanged. @@ -180,13 +203,14 @@ device, tap Send, and read results on screen. Concretely: Real-app perf-feel is debug-misleading (interpreter JS, no R8/ProGuard, unminified RN bundle). Both build types must work end-to-end: -- Android: the `bench` productFlavor is orthogonal to `debug` / - `release` build types. The bench sourceSet only adds assets, which - R8/ProGuard never touch, so a `release` variant of the bench app - bundles the bench `nodejs-project/` exactly as `debug` does. - Verification: `eas build --profile production-apk --platform android` - (or `./gradlew :app:assembleBenchRelease`) and unzip-grep the APK. -- iOS: the resource toggle via `ENV['COMAPEO_BENCH']` runs at +- Android: the `comapeoBench=true` Gradle property is orthogonal to + `debug` / `release` build types — it just selects which `assets` + srcDirs the AAR ships. R8 / ProGuard don't touch assets, so a + `release` build of the bench app bundles the same bench + `nodejs-project/` as `debug`. Verification: + `expo run:android --variant release` (or `./gradlew :app:assembleRelease` + in the prebuild output) and unzip-grep the APK. +- iOS: the podspec's `ENV['COMAPEO_BENCH']` check + staging copy run at `pod install` time, before any per-configuration build, so Release and Debug configurations both embed the bench bundle identically. Verification: archive the bench app with the Release configuration @@ -205,18 +229,18 @@ unminified RN bundle). Both build types must work end-to-end: - `backend/rollup.config.ts` — accept the entry override; exclude `@comapeo/core` and its drizzle migrations from the bench bundle. **Module-side wiring for the bench variant:** -- `android/build.gradle` — declare `flavorDimensions "comapeo"`, a `productFlavors { production {}; bench {} }` block, and a `sourceSets.bench { assets.srcDirs 'src/bench/assets' }`. Default consumer (`apps/example/` and third parties) compiles `production` only; the bench sourceSet is ignored. Bench app activates the `bench` flavor via `missingDimensionStrategy` injected by its config plugin. -- `ios/ComapeoCore.podspec` — read `ENV['COMAPEO_BENCH']` at evaluation time and, when set, append `nodejs-project-bench` to `s.resources`. Default consumers leave the env var unset and ship the existing single `nodejs-project` resource. -- `package.json` — `files` array stays as-is; `android/src/bench/` and `ios/nodejs-project-bench/` are deliberately omitted so they cannot leak via `npm publish`. +- `android/build.gradle` — read `rootProject.findProperty('comapeoBench')`. When set, reassign `sourceSets.main.assets.srcDirs = ['src/bench/assets']` (replacing — not appending — the production `src/main/assets`) and zero out `sourceSets.debug.assets.srcDirs` so the debug overlay can't shadow bench in debug builds. Default consumer (`apps/example/`, third parties) leaves the property unset and keeps the production `src/main/assets`. The earlier `productFlavors` design hit AGP variant-attribute ambiguity for Expo apps without matching flavors of their own. +- `ios/ComapeoCore.podspec` — read `ENV['COMAPEO_BENCH']` at evaluation time. When set, stage `ios/nodejs-project-bench/` to `ios/.bench-staging/nodejs-project/` and add it to `s.resources` alongside the production `nodejs-project/`. CocoaPods' `[CP] Copy Pods Resources` rsyncs both into `.app/nodejs-project/` in declaration order, with the bench `index.mjs` overlaying. Default consumers leave the env var unset and ship the existing single `nodejs-project` resource, byte-identical. +- `package.json` — `files` array stays as-is; `android/src/bench/`, `ios/nodejs-project-bench/`, and `ios/.bench-staging/` are deliberately omitted (and explicitly negated via `!`-patterns) so they cannot leak via `npm publish`. **Bench app (no checked-in `android/`/`ios/`):** - `apps/benchmark/` *(new)* — slim sibling of `apps/example/`, but only owns: `App.tsx`, `app.json`, `babel.config.js`, `metro.config.js`, `index.ts`, `package.json`, `tsconfig.json`, and `plugins/`. Native dirs are not checked in; `expo prebuild` generates them on demand using the config plugin below. Uses Expo autolinking back to `../..`, same pattern as `apps/example`. - `apps/benchmark/app.json` *(new)* — declares the bench plugin **only**: `"plugins": ["./plugins/with-comapeo-bench"]`. Does **not** include `with-android-tests` or `with-ios-tests` from `apps/example/plugins/`. - `apps/benchmark/App.tsx` *(new)* — UI with `testID="send-button"`, `testID="benchmark-result"`, payload-size selector, warmup/steady-state toggle, on-screen p50/p95/p99 render, "Export results" button, and an opt-in "POST to receiver" toggle + URL field (default `http://localhost:`, off by default). -- `apps/benchmark/plugins/with-comapeo-bench/` *(new)* — single config plugin. Uses canonical `@expo/config-plugins` mods only (no `withDangerousMod` — research confirmed neither `expo-build-properties` nor any `@config-plugins/*` package covers product flavors or Podfile env-var injection, so a custom plugin is unavoidable, but the standard mods suffice): - - `withAppBuildGradle` — appends `flavorDimensions += "comapeo"` and `missingDimensionStrategy 'comapeo', 'bench'` inside `android.defaultConfig`. Also adds `matchingFallbacks = ['production']` on the `debug` and `release` build types to avoid the known Expo / AGP footgun where the consuming app's variants don't resolve against the module's `bench` flavor (expo/expo#18315, #16686, #23266). Each insertion guarded by an `includes(sentinel)` check for idempotency across `expo prebuild` re-runs. - - `withPodfile` — prepends `ENV['COMAPEO_BENCH'] = '1'` to `ios/Podfile` above the autolinking block. Same idempotency guard. - - `withXcodeProject` — adds the resource-rename build phase mapping `nodejs-project-bench/` → `nodejs-project/` in the embedded app bundle. +- `apps/benchmark/plugins/with-comapeo-bench/` *(new)* — single config plugin. Uses canonical `@expo/config-plugins` mods only: + - `withGradleProperties` — writes `comapeoBench=true` into `android/gradle.properties`. Idempotent (lookup-then-update). The module's `android/build.gradle` reads this and swaps in the bench asset srcDirs. + - `withPodfile` — prepends `ENV['COMAPEO_BENCH'] = '1'` to `ios/Podfile` above the autolinking block, guarded by an `includes(sentinel)` check for idempotency. The podspec reads the env var at pod install time and stages the bench bundle. + - No `withXcodeProject` — an earlier iteration tried to add an Xcode Run Script build phase to rename `nodejs-project-bench/` → `nodejs-project/` in the app bundle, but CocoaPods 1.x doesn't reliably position user script phases after `[CP] Copy Pods Resources`, so the rename ran before the bench files were on disk and silently no-op'd. The pod-install-time staging in the podspec sidesteps the ordering problem entirely. **RN-side timing hook:** - `src/ComapeoCoreModule.ts` (`CoreMessagePort`) — accept an optional JS-side `recordSpan` so RN-thread timestamps round-trip with each RPC. Same structural shape Sentry plan §6.2 will need later. @@ -250,13 +274,13 @@ Two transports, ranked by who's running the bench: `backend/index.bench.js` skeleton with boot-phase spans wired. Verify locally with `JsonFileSink` against a dev build. 2. **Phase 2 (2–3 days):** dual-bundle build wiring + consumer isolation - (`scripts/build-backend.ts --bench`, rollup config, Android `bench` - productFlavor in the module's `android/build.gradle`, env-driven - resource toggle in the module's `ios/ComapeoCore.podspec`). Confirm - production `nodejs-project/` is byte-identical to before; bench - bundle lands in `android/src/bench/...` / `ios/nodejs-project-bench/` - and is absent from a default `apps/example/` build (release variant - too). + (`scripts/build-backend.ts --bench`, rollup config, `comapeoBench` + Gradle property toggle in the module's `android/build.gradle`, + env-var-driven resource staging in the module's + `ios/ComapeoCore.podspec`). Confirm production `nodejs-project/` is + byte-identical to before; bench bundle lands in + `android/src/bench/...` / `ios/nodejs-project-bench/` and is absent + from a default `apps/example/` build (release variant too). 3. **Phase 3 (3–5 days):** `apps/benchmark/` skeleton (no checked-in `android/`/`ios/`) with `App.tsx` UI, RPC bridge wiring, per-payload-size handlers, warmup/steady-state logic, on-screen p50/p95/p99 render, diff --git a/ios/ComapeoCore.podspec b/ios/ComapeoCore.podspec index 7330aef..3ee437c 100644 --- a/ios/ComapeoCore.podspec +++ b/ios/ComapeoCore.podspec @@ -1,4 +1,5 @@ require 'json' +require 'fileutils' package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) @@ -46,22 +47,40 @@ Pod::Spec.new do |s| # ship separately via `Frameworks/*.xcframework` (above) and are embedded # + codesigned by Xcode automatically. # - # Bench opt-in: when `ENV['COMAPEO_BENCH']` is set at `pod install` time, - # the bench-only resource bundle (`nodejs-project-bench/`, produced by - # `scripts/build-backend.ts --bench`) REPLACES the production - # `nodejs-project/` in the resources list. The `with-comapeo-bench` - # Expo config plugin in `apps/benchmark/` flips that env var by - # prepending `ENV['COMAPEO_BENCH'] = '1'` to the consuming app's - # Podfile via `withPodfile`. Default consumers (`apps/example/`, third - # parties) leave the env var unset and ship only the production - # `nodejs-project/`. The bench bundle is then renamed to - # `nodejs-project/` in the embedded app bundle by a Run Script build - # phase the same plugin adds via `withXcodeProject` — so the native - # loader continues to look at a fixed `nodejs-project/` path regardless - # of which flavor is active. + # Bench opt-in: when `ENV['COMAPEO_BENCH']` is set at `pod install` time + # AND the bench bundle (`nodejs-project-bench/`, produced by + # `scripts/build-backend.ts --bench`) is on disk, this podspec stages + # the bench bundle to `.bench-staging/nodejs-project/` and declares it + # in `s.resources` ALONGSIDE the production `nodejs-project/`. + # CocoaPods' `[CP] Copy Pods Resources` rsyncs both into the app + # bundle in declaration order, with the same destination basename, so + # the bench bundle's `index.mjs` overlays the production bundle's + # without changing the path the native loader looks at. This avoids + # the build-phase ordering footgun that an Xcode Run Script approach + # hit (CocoaPods 1.x can't reliably position a user script phase + # after `[CP] Copy Pods Resources`); the staging here happens at + # podspec evaluation time, before any Xcode build phase runs. + # + # Robustness: if the bench bundle is missing (forgot to run + # `--bench`), the staging step is skipped and only the production + # bundle ships. The bench app boots production rather than crashing + # on a missing resource — graceful degradation. Default consumers + # (`apps/example/`, third parties) leave the env var unset and ship + # exactly today's production `nodejs-project/` byte-identically. + resources = ['nodejs-project'] if ENV['COMAPEO_BENCH'] == '1' - s.resources = ['nodejs-project-bench'] - else - s.resources = ['nodejs-project'] + bench_src = File.join(__dir__, 'nodejs-project-bench') + if File.directory?(bench_src) + bench_staged = File.join(__dir__, '.bench-staging', 'nodejs-project') + FileUtils.rm_rf(bench_staged) + FileUtils.mkdir_p(File.dirname(bench_staged)) + FileUtils.cp_r(bench_src, bench_staged) + # Order matters: `nodejs-project` first so the bench overlay + # rsyncs over it. (CP iterates the list in declaration order; + # rsync overlay leaves prod-only files like drizzle SQL intact + # but unused — the bench `index.mjs` is what the loader reads.) + resources << '.bench-staging/nodejs-project' + end end + s.resources = resources end diff --git a/scripts/build-backend.ts b/scripts/build-backend.ts index 7ea41dd..6169ed4 100755 --- a/scripts/build-backend.ts +++ b/scripts/build-backend.ts @@ -43,10 +43,13 @@ const ANDROID_MAIN_NODEJS_PROJECT_DIR = join( PROJECT_ROOT, "android/src/main/assets/nodejs-project", ); -// Bench bundle output. Lives under `src/bench/assets/` so AGP's -// per-flavor sourceSet merging picks it up only when the consuming app -// has activated the `bench` productFlavor (see android/build.gradle — -// `apps/benchmark/` activates this; `apps/example/` does not). +// Bench bundle output. Lives under `src/bench/assets/` so the module's +// `android/build.gradle` only swaps it in when the consuming app sets +// `comapeoBench=true` in its `gradle.properties` (a project property, +// not a productFlavor — the flavor approach hit AGP / Gradle 9 strict +// variant resolution issues for Expo apps without matching flavors). +// `apps/benchmark/`'s `with-comapeo-bench` config plugin sets it; +// `apps/example/` and third-party apps don't. const ANDROID_BENCH_NODEJS_PROJECT_DIR = join( PROJECT_ROOT, "android/src/bench/assets/nodejs-project", @@ -81,8 +84,9 @@ if (IS_BENCH) { // imports, so no prebuilds / JNI / xcframework packaging is needed — // the production build run already laid those down. We bundle into // bench-specific output dirs (`android/src/bench/assets/` and - // `ios/nodejs-project-bench/`) which are activated by the `bench` - // Android productFlavor / `ENV['COMAPEO_BENCH']` iOS env var. + // `ios/nodejs-project-bench/`) which are activated by the + // `comapeoBench=true` Gradle property on Android / `ENV['COMAPEO_BENCH']` + // iOS env var (consumed by `ios/ComapeoCore.podspec`). await $({ cwd: BACKEND_SRC_DIR, stdio: "inherit", diff --git a/scripts/lib/bench-receiver.ts b/scripts/lib/bench-receiver.ts index 7a26b65..cad6e7c 100644 --- a/scripts/lib/bench-receiver.ts +++ b/scripts/lib/bench-receiver.ts @@ -76,7 +76,15 @@ function safeRunId(raw: unknown, fallback: string): string { function percentile(sortedAsc: number[], p: number): number { if (sortedAsc.length === 0) return Number.NaN; - return sortedAsc[Math.floor((sortedAsc.length - 1) * p)]!; + // Linear interpolation between closest ranks — matches the on-device + // calculation in apps/benchmark/App.tsx so on-screen and host-side + // numbers agree. + const position = (sortedAsc.length - 1) * p; + const lower = Math.floor(position); + const upper = Math.ceil(position); + if (lower === upper) return sortedAsc[lower]!; + const weight = position - lower; + return sortedAsc[lower]! + (sortedAsc[upper]! - sortedAsc[lower]!) * weight; } function rewriteSummary(outDir: string): void { From 214d9b01084b2faa39577762795d31dcd7b8bf55 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 12:45:22 +0100 Subject: [PATCH 06/33] feat(module): add comapeoBackendDir override hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a generic config knob for consumers that ship their own backend JS bundle: `comapeoBackendDir` Gradle property → BuildConfig field on Android, `ComapeoBackendDir` Info.plist key on iOS. Default is `nodejs-project` so behavior is unchanged for current consumers. This unblocks moving bench-specific wiring out of the module: the bench app can now ship its bundle in a sibling directory and just flip this override, instead of relying on an in-module `comapeoBench=true` toggle that swaps Android sourceSets and runs an iOS pod-install staging copy. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/build.gradle | 20 +++++++++++++++++++ .../java/com/comapeo/core/NodeJSService.kt | 7 ++++++- ios/AppLifecycleDelegate.swift | 10 +++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 2b9d369..5055abb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -60,6 +60,18 @@ def comapeoAbiFilters = { // `android/gradle.properties`. def comapeoBenchEnabled = (rootProject.findProperty('comapeoBench') ?: project.findProperty('comapeoBench'))?.toString() == 'true' +// Generic override for the assets subdirectory the loader reads +// `index.mjs` from. Defaults to `nodejs-project` (the production +// bundle path emitted by `scripts/build-backend.ts`). A consumer that +// ships its own backend bundle in a sibling assets directory (e.g. +// `nodejs-bench/`) sets `comapeoBackendDir=nodejs-bench` in its +// `android/gradle.properties` and supplies the directory at +// `app/src/main/assets/nodejs-bench/` itself; AGP's normal asset merge +// places it alongside `nodejs-project/` in the APK and the loader +// reads the override path. Surface is intentionally generic so it +// isn't bench-specific. +def comapeoBackendDir = (rootProject.findProperty('comapeoBackendDir') ?: project.findProperty('comapeoBackendDir') ?: 'nodejs-project').toString() + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") apply from: expoModulesCorePlugin applyKotlinExpoModulesCorePlugin() @@ -94,6 +106,11 @@ android { versionCode 1 versionName "0.1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Surfaces `comapeoBackendDir` to runtime Kotlin via + // `BuildConfig.COMAPEO_BACKEND_DIR`. The Java-string literal + // form (with embedded quotes) is what `buildConfigField` + // requires. + buildConfigField "String", "COMAPEO_BACKEND_DIR", "\"${comapeoBackendDir}\"" externalNativeBuild { cmake { cppFlags '' @@ -145,6 +162,9 @@ android { } buildFeatures { prefab true + // Required for `buildConfigField` above to actually generate + // the BuildConfig class; AGP 8.x flipped the default to off. + buildConfig true } packagingOptions { excludes += [ diff --git a/android/src/main/java/com/comapeo/core/NodeJSService.kt b/android/src/main/java/com/comapeo/core/NodeJSService.kt index efb5b25..2ab7ba9 100644 --- a/android/src/main/java/com/comapeo/core/NodeJSService.kt +++ b/android/src/main/java/com/comapeo/core/NodeJSService.kt @@ -42,7 +42,12 @@ private data class ErrorNativeMessage( const val APK_LAST_UPDATE_TIME_KEY = "apk_last_update_time" const val SHARED_PREFS_NAME_POSTFIX = "_nodejs_preferences" -const val NODEJS_PROJECT_DIRNAME = "nodejs-project" +// Asset subdirectory the loader copies into the app's filesDir and +// runs `index.mjs` from. Sourced from `BuildConfig.COMAPEO_BACKEND_DIR` +// so consumers can override via the `comapeoBackendDir` Gradle +// property (default `nodejs-project`). See android/build.gradle for +// the buildConfigField declaration. +val NODEJS_PROJECT_DIRNAME: String = BuildConfig.COMAPEO_BACKEND_DIR const val NODEJS_PROJECT_INDEX_FILENAME = "index.mjs" /** diff --git a/ios/AppLifecycleDelegate.swift b/ios/AppLifecycleDelegate.swift index 0a53beb..b117992 100644 --- a/ios/AppLifecycleDelegate.swift +++ b/ios/AppLifecycleDelegate.swift @@ -98,8 +98,16 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { // Android extracts on first launch instead because the APK // doesn't expose a filesystem-readable path to its assets // the way `.app//` does on iOS. + // + // Bundle-subdir name is read from the consumer app's + // `Info.plist` `ComapeoBackendDir` key (default + // `nodejs-project`). Lets a consumer ship its own backend + // bundle in a sibling directory inside the .app without + // touching this module's podspec — they bundle the dir as + // an Xcode resource and set the Info.plist key to its name. + let dirName = (Bundle.main.object(forInfoDictionaryKey: "ComapeoBackendDir") as? String) ?? "nodejs-project" let bundleEntry = (Bundle.main.bundlePath as NSString) - .appendingPathComponent("nodejs-project/index.mjs") + .appendingPathComponent("\(dirName)/index.mjs") return FileManager.default.fileExists(atPath: bundleEntry) ? bundleEntry : nil From 97ea28d4643a98c9fc6b0259a3f9189fb8b40fcd Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 12:54:43 +0100 Subject: [PATCH 07/33] refactor(bench): move bench backend + plugin into apps/benchmark Moves all bench-only backend source (`index.bench.js`, `bench-rpc.js`, `boot-spans.js`, `telemetry-sink.js`) and its rollup config out of the production module and into `apps/benchmark/backend/`. The bench bundle is built from there with its own simplified rollup config: one ESM output, no per-platform split, no native-addon banner (the bench code imports no addons). Shared framing helpers (server-helper.js, simple-rpc.js, message-port.js) stay in the module's `backend/lib/` and are path-imported from the bench source so wire framing stays bit-identical to production. Rewrites `with-comapeo-bench` plugin against the new `comapeoBackendDir` override hook: drops `comapeoBench=true` Gradle toggle, drops `ENV['COMAPEO_BENCH']` Podfile mutation, drops the iOS `.bench-staging` rsync trick. Now sets the override property/Info.plist key and copies the bench bundle into the consumer app's own native asset/resource trees (Android assets dir + iOS folder reference). Same shape as `expo-asset`'s plugin, minus its file-extension allowlist and flat-structure constraints which don't fit a JS bundle. Strips `BENCH=1` mode from the module's rollup.config.ts and `--bench` mode from scripts/build-backend.ts. Dead bench wiring still in the module (`android/src/bench/`, `ios/nodejs-project-bench/`, podspec env branch) is removed in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../benchmark/backend/index.js | 7 +- .../benchmark/backend}/lib/bench-rpc.js | 7 +- .../benchmark/backend}/lib/boot-spans.js | 0 .../benchmark/backend}/lib/telemetry-sink.js | 0 apps/benchmark/backend/package-lock.json | 1539 +++++++++++++++++ apps/benchmark/backend/package.json | 23 + apps/benchmark/backend/rollup.config.js | 79 + apps/benchmark/package.json | 13 +- .../plugins/with-comapeo-bench/index.js | 207 ++- backend/rollup.config.ts | 120 +- scripts/build-backend.ts | 57 - 11 files changed, 1825 insertions(+), 227 deletions(-) rename backend/index.bench.js => apps/benchmark/backend/index.js (95%) rename {backend => apps/benchmark/backend}/lib/bench-rpc.js (93%) rename {backend => apps/benchmark/backend}/lib/boot-spans.js (100%) rename {backend => apps/benchmark/backend}/lib/telemetry-sink.js (100%) create mode 100644 apps/benchmark/backend/package-lock.json create mode 100644 apps/benchmark/backend/package.json create mode 100644 apps/benchmark/backend/rollup.config.js diff --git a/backend/index.bench.js b/apps/benchmark/backend/index.js similarity index 95% rename from backend/index.bench.js rename to apps/benchmark/backend/index.js index 6d999e7..9f91a47 100644 --- a/backend/index.bench.js +++ b/apps/benchmark/backend/index.js @@ -1,6 +1,11 @@ import { BenchRpcServer } from "./lib/bench-rpc.js"; import { startBootSpan } from "./lib/boot-spans.js"; -import { SimpleRpcServer } from "./lib/simple-rpc.js"; +// Path-imported from the module's production backend so the bench +// bundle exercises the same control-socket framing as production — +// rollup inlines this at bundle time. Out-of-tree imports for shared +// helpers are intentional: any divergence here would invalidate the +// benchmark's whole premise. +import { SimpleRpcServer } from "../../../backend/lib/simple-rpc.js"; import { createSinkFromArg } from "./lib/telemetry-sink.js"; /** diff --git a/backend/lib/bench-rpc.js b/apps/benchmark/backend/lib/bench-rpc.js similarity index 93% rename from backend/lib/bench-rpc.js rename to apps/benchmark/backend/lib/bench-rpc.js index 5e23a75..1284904 100644 --- a/backend/lib/bench-rpc.js +++ b/apps/benchmark/backend/lib/bench-rpc.js @@ -1,5 +1,8 @@ -import { ServerHelper } from "./server-helper.js"; -import { SocketMessagePort } from "./message-port.js"; +// Path-imported from the module's production backend — see +// apps/benchmark/backend/index.js for the rationale. Same wire framing +// as production, by design. +import { ServerHelper } from "../../../../backend/lib/server-helper.js"; +import { SocketMessagePort } from "../../../../backend/lib/message-port.js"; import { startSpan } from "./telemetry-sink.js"; /** diff --git a/backend/lib/boot-spans.js b/apps/benchmark/backend/lib/boot-spans.js similarity index 100% rename from backend/lib/boot-spans.js rename to apps/benchmark/backend/lib/boot-spans.js diff --git a/backend/lib/telemetry-sink.js b/apps/benchmark/backend/lib/telemetry-sink.js similarity index 100% rename from backend/lib/telemetry-sink.js rename to apps/benchmark/backend/lib/telemetry-sink.js diff --git a/apps/benchmark/backend/package-lock.json b/apps/benchmark/backend/package-lock.json new file mode 100644 index 0000000..2ffbb6d --- /dev/null +++ b/apps/benchmark/backend/package-lock.json @@ -0,0 +1,1539 @@ +{ + "name": "core-react-native-benchmark-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "core-react-native-benchmark-backend", + "version": "1.0.0", + "dependencies": { + "ensure-error": "4.0.0", + "framed-stream": "1.0.1", + "tiny-typed-emitter": "2.1.0" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "29.0.2", + "@rollup/plugin-esm-shim": "0.1.8", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-node-resolve": "16.0.3", + "rollup": "4.60.2", + "rollup-plugin-esbuild": "6.2.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-esm-shim": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@rollup/plugin-esm-shim/-/plugin-esm-shim-0.1.8.tgz", + "integrity": "sha512-xEU0b/BShgDDSPjidhJd4R74J9xZ9jLVtFWNGtsUXyEsdwwwB1a3XOAwwGaNIyUHD6EhxPO21JMfUmJWoMn7SA==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.3", + "mlly": "^1.7.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ensure-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ensure-error/-/ensure-error-4.0.0.tgz", + "integrity": "sha512-7Xenn3+R6tp2UqAbH9Jqs6QCSABQok+1VAhaPaF0jjm3iuhVHCblfBh18nYtpm3K9/V4Jpxz1JIqFZyrjstBtw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/framed-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/framed-stream/-/framed-stream-1.0.1.tgz", + "integrity": "sha512-x0At1ETY20yXARxM6Qr5BGasgPpD0/HVYDyA7Uiaf9LfSqrb8p/F9i0s2/8YoeHkZcJeKsnwWBrnJCfZZoxHjA==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.1", + "streamx": "^2.13.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-esbuild": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-6.2.1.tgz", + "integrity": "sha512-jTNOMGoMRhs0JuueJrJqbW8tOwxumaWYq+V5i+PD+8ecSCVkuX27tGW7BXqDgoULQ55rO7IdNxPcnsWtshz3AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "get-tsconfig": "^4.10.0", + "unplugin-utils": "^0.2.4" + }, + "engines": { + "node": ">=14.18.0" + }, + "peerDependencies": { + "esbuild": ">=0.18.0", + "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin-utils": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.2.5.tgz", + "integrity": "sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + } + } +} diff --git a/apps/benchmark/backend/package.json b/apps/benchmark/backend/package.json new file mode 100644 index 0000000..75a396a --- /dev/null +++ b/apps/benchmark/backend/package.json @@ -0,0 +1,23 @@ +{ + "name": "core-react-native-benchmark-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "dist/index.mjs", + "scripts": { + "build": "rollup -c" + }, + "dependencies": { + "ensure-error": "4.0.0", + "framed-stream": "1.0.1", + "tiny-typed-emitter": "2.1.0" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "29.0.2", + "@rollup/plugin-esm-shim": "0.1.8", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-node-resolve": "16.0.3", + "rollup": "4.60.2", + "rollup-plugin-esbuild": "6.2.1" + } +} diff --git a/apps/benchmark/backend/rollup.config.js b/apps/benchmark/backend/rollup.config.js new file mode 100644 index 0000000..d106129 --- /dev/null +++ b/apps/benchmark/backend/rollup.config.js @@ -0,0 +1,79 @@ +import { rmSync } from "node:fs"; +import { cp } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import { default as esmShim } from "@rollup/plugin-esm-shim"; +import json from "@rollup/plugin-json"; +import { minify } from "rollup-plugin-esbuild"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Bench bundle output. The bench app's `with-comapeo-bench` config + * plugin reads from this path during `expo prebuild` and copies the + * tree into the consumer app's native asset/resource trees so + * nodejs-mobile can find `/index.mjs` at runtime. + */ +const OUT_DIR = path.join(__dirname, "dist"); + +/** + * The bench backend imports framing helpers (server-helper.js, + * simple-rpc.js, message-port.js) from the production backend's + * `backend/lib/` via path-relative imports — keeps the wire framing + * bit-identical to production, which is the whole point of the + * benchmark. Rollup's `nodeResolve` walks the path-imported tree and + * brings the helpers into the bundle directly; their dependencies + * (`framed-stream`, `tiny-typed-emitter`, `ensure-error`) are listed + * in this package's package.json and resolved out of + * `apps/benchmark/backend/node_modules/`. + * + * Unlike the production rollup config there is no per-platform split: + * the bench code never imports `@comapeo/core` (so no iOS maps-plugin + * stub is needed) and never loads native addons (so no + * platform-specific `__loadAddon` banner is needed). One ESM bundle + * works on Android and iOS alike. + */ +function copyPackageJsonPlugin() { + return { + name: "copy-package-json", + async writeBundle() { + // Node's module resolver reads the unpacked tree's package.json + // to set `"type": "module"` so `index.mjs` evaluates as ESM. + await cp( + path.join(__dirname, "package.json"), + path.join(OUT_DIR, "package.json"), + ); + }, + }; +} + +function cleanOutputDirPlugin() { + return { + name: "clean-output-dir", + buildStart() { + rmSync(OUT_DIR, { force: true, recursive: true }); + }, + }; +} + +export default { + input: { index: path.join(__dirname, "index.js") }, + output: { + dir: OUT_DIR, + format: "esm", + sourcemap: true, + entryFileNames: "[name].mjs", + }, + plugins: [ + cleanOutputDirPlugin(), + commonjs({ ignoreDynamicRequires: true }), + esmShim(), + nodeResolve({ preferBuiltins: true }), + json(), + minify(), + copyPackageJsonPlugin(), + ], +}; diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json index 332c874..b77515d 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -4,11 +4,14 @@ "main": "index.ts", "scripts": { "start": "expo start --dev-client", - "prebuild": "expo prebuild --no-install", - "android": "expo run:android", - "android:release": "expo run:android --variant release --no-install", - "ios": "expo run:ios", - "ios:release": "expo run:ios --configuration Release" + "backend:install": "npm ci --prefix backend", + "backend:build": "npm run build --prefix backend", + "prebuild:bundle": "npm run backend:install && npm run backend:build", + "prebuild": "npm run prebuild:bundle && expo prebuild --no-install", + "android": "npm run prebuild:bundle && expo run:android", + "android:release": "npm run prebuild:bundle && expo run:android --variant release --no-install", + "ios": "npm run prebuild:bundle && expo run:ios", + "ios:release": "npm run prebuild:bundle && expo run:ios --configuration Release" }, "dependencies": { "expo": "55.0.17", diff --git a/apps/benchmark/plugins/with-comapeo-bench/index.js b/apps/benchmark/plugins/with-comapeo-bench/index.js index 77b0cc8..7e7d4ea 100644 --- a/apps/benchmark/plugins/with-comapeo-bench/index.js +++ b/apps/benchmark/plugins/with-comapeo-bench/index.js @@ -1,86 +1,185 @@ const { + IOSConfig, + withDangerousMod, withGradleProperties, - withPodfile, + withInfoPlist, + withXcodeProject, } = require('@expo/config-plugins'); +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); /** - * Opts this Expo app into the benchmark JS bundle on every + * Wires the bench backend bundle into the consumer Expo app on every * `expo prebuild`. The bench app (`apps/benchmark/`) does not check in * `android/` or `ios/`, so this plugin is the single source of truth * for the native wiring; production consumers (`apps/example/`, third - * parties) never apply it and never see any bench artefacts. + * parties) never apply it. * - * Two idempotent mutations: + * Conceptually a stripped-down `expo-asset` plugin: copy a directory + * tree into the prebuild output's native asset/resource locations and + * point the module's loader at it via the override hook the module + * exposes. Three mutations: * - * - `withGradleProperties` writes `comapeoBench=true` into the - * consuming app's `android/gradle.properties`. The module's - * `android/build.gradle` reads `rootProject.findProperty('comapeoBench')` - * and, when set, swaps `assets.srcDirs` to `src/bench/assets/` - * (containing the bench `nodejs-project/index.mjs`). The property - * mechanism is intentionally chosen over a productFlavor to avoid - * AGP / Gradle 9 variant-attribute ambiguity for Expo apps that - * don't declare matching flavors of their own. + * 1. `withGradleProperties` — sets `comapeoBackendDir=nodejs-bench` + * in the consumer app's `android/gradle.properties`. The module's + * `android/build.gradle` reads this into + * `BuildConfig.COMAPEO_BACKEND_DIR`; the Kotlin loader uses it + * as the assets-subdir name when copying the bundle into + * `filesDir/` on first launch. * - * - `withPodfile` prepends `ENV['COMAPEO_BENCH'] = '1'` to the top of - * `ios/Podfile` so it's set BEFORE Expo autolinking evaluates - * `ComapeoCore.podspec`. The podspec reads that env var and stages - * the bench bundle (`nodejs-project-bench/`) to - * `.bench-staging/nodejs-project/`, then declares both the staged - * bench bundle and the production `nodejs-project/` in - * `s.resources` — CocoaPods' `[CP] Copy Pods Resources` rsyncs - * them in declaration order so the bench overlay lands on top. - * No Xcode Run Script build phase is needed (the previous - * iteration hit a CocoaPods 1.x ordering footgun where user - * script phases can't reliably be positioned after - * `[CP] Copy Pods Resources`). + * 2. `withInfoPlist` — sets `ComapeoBackendDir=nodejs-bench` on the + * consumer app's `Info.plist`. The module's `AppLifecycleDelegate` + * reads this in `resolveJSEntryPoint` to pick the bundle path + * inside the `.app`. * - * Each mutation is idempotent so re-runs of `expo prebuild` don't - * accumulate duplicate insertions. + * 3. `withDangerousMod` (Android + iOS) — copies the rolled-up bench + * bundle from `apps/benchmark/backend/dist/` into: + * Android: `/app/src/main/assets/nodejs-bench/` + * iOS: `//nodejs-bench/` + * Plus `withXcodeProject` registers the iOS dir as a blue-folder + * reference (`lastKnownFileType=folder`) under the project's + * Resources group so Xcode preserves the directory structure when + * copying it into the `.app` bundle. + * + * The bench bundle build is NOT triggered here — the bench app's + * `package.json` `prebuild` script runs `npm run --prefix backend + * build` before `expo prebuild`. Running rollup from inside a config + * plugin would surprise developers who invoke `expo prebuild` directly, + * so we fail fast with a helpful error if the bundle is missing. + * + * Plugin name retained from the prior bench-toggle implementation + * (`with-comapeo-bench`) so existing `app.json` references continue + * to work. */ +const BENCH_BUNDLE_DIR_NAME = 'nodejs-bench'; +const BENCH_BUNDLE_SOURCE_DIR = path.resolve( + __dirname, + '../../backend/dist', +); +const BENCH_BUNDLE_ENTRY = 'index.mjs'; + function withComapeoBench(config) { - config = withBenchGradleProperties(config); - config = withBenchPodfile(config); + config = withBenchGradleProperty(config); + config = withBenchInfoPlist(config); + config = withBenchAndroidAssets(config); + config = withBenchIosResources(config); return config; } -function withBenchGradleProperties(config) { +function withBenchGradleProperty(config) { return withGradleProperties(config, (cfg) => { - const props = cfg.modResults; - const existing = props.find( - (p) => p.type === 'property' && p.key === 'comapeoBench', + upsertGradleProperty( + cfg.modResults, + 'comapeoBackendDir', + BENCH_BUNDLE_DIR_NAME, + ' bench bundle override — read by android/build.gradle of @comapeo/core-react-native', ); - if (existing) { - existing.value = 'true'; - } else { - props.push({ - type: 'comment', - value: ' bench bundle opt-in — read by android/build.gradle of @comapeo/core-react-native', - }); - props.push({ type: 'property', key: 'comapeoBench', value: 'true' }); - } return cfg; }); } -const PODFILE_ENV_LINE = "ENV['COMAPEO_BENCH'] = '1'"; +function withBenchInfoPlist(config) { + return withInfoPlist(config, (cfg) => { + cfg.modResults.ComapeoBackendDir = BENCH_BUNDLE_DIR_NAME; + return cfg; + }); +} -function withBenchPodfile(config) { - return withPodfile(config, (cfg) => { - let contents = cfg.modResults.contents; +function withBenchAndroidAssets(config) { + return withDangerousMod(config, [ + 'android', + async (cfg) => { + assertBundleExists(); + const destDir = path.join( + cfg.modRequest.platformProjectRoot, + 'app', + 'src', + 'main', + 'assets', + BENCH_BUNDLE_DIR_NAME, + ); + await replaceDirectory(BENCH_BUNDLE_SOURCE_DIR, destDir); + return cfg; + }, + ]); +} - if (contents.includes(PODFILE_ENV_LINE)) { - // Already applied by a prior prebuild run. +function withBenchIosResources(config) { + // 1. Copy the bundle into the Xcode project source root so it sits + // next to the app's other in-tree resources. + config = withDangerousMod(config, [ + 'ios', + async (cfg) => { + assertBundleExists(); + const projectName = IOSConfig.XcodeUtils.getProjectName( + cfg.modRequest.projectRoot, + ); + const destDir = path.join( + cfg.modRequest.platformProjectRoot, + projectName, + BENCH_BUNDLE_DIR_NAME, + ); + await replaceDirectory(BENCH_BUNDLE_SOURCE_DIR, destDir); + return cfg; + }, + ]); + // 2. Register the directory as a folder reference (blue folder) on + // the app target. `lastKnownFileType=folder` is the standard + // Xcode encoding for "preserve directory structure when copying + // to the .app" — what we need so nodejs-mobile finds + // `.app/nodejs-bench/index.mjs` at runtime. + config = withXcodeProject(config, (cfg) => { + const project = cfg.modResults; + const projectName = IOSConfig.XcodeUtils.getProjectName( + cfg.modRequest.projectRoot, + ); + const relPath = path.join(projectName, BENCH_BUNDLE_DIR_NAME); + // Idempotency: re-running prebuild shouldn't accumulate duplicate + // file refs. `pbxProject.hasFile` checks by `file.path`. + if (project.hasFile && project.hasFile(relPath)) { return cfg; } - - // Prepend at the top so it runs before any autolinking-block code - // that evaluates `ComapeoCore.podspec` (which reads the env var). - cfg.modResults.contents = - `# @generated by with-comapeo-bench (apps/benchmark/plugins) — bench-only opt-in\n` + - `${PODFILE_ENV_LINE}\n\n` + - contents; + project.addResourceFile( + relPath, + { lastKnownFileType: 'folder' }, + undefined, + ); return cfg; }); + return config; +} + +function upsertGradleProperty(props, key, value, comment) { + const existing = props.find( + (p) => p.type === 'property' && p.key === key, + ); + if (existing) { + existing.value = value; + return; + } + if (comment) { + props.push({ type: 'comment', value: comment }); + } + props.push({ type: 'property', key, value }); +} + +async function replaceDirectory(srcDir, destDir) { + await fsp.rm(destDir, { recursive: true, force: true }); + await fsp.mkdir(path.dirname(destDir), { recursive: true }); + await fsp.cp(srcDir, destDir, { recursive: true }); +} + +function assertBundleExists() { + const entry = path.join(BENCH_BUNDLE_SOURCE_DIR, BENCH_BUNDLE_ENTRY); + if (!fs.existsSync(entry)) { + throw new Error( + `with-comapeo-bench: bench bundle not found at ${entry}.\n` + + `Run \`npm install --prefix backend && npm run build --prefix backend\` ` + + `from apps/benchmark/ (or \`npm run prebuild\` which does both) ` + + `before \`expo prebuild\`.`, + ); + } } module.exports = withComapeoBench; diff --git a/backend/rollup.config.ts b/backend/rollup.config.ts index 7b33d7b..833add7 100644 --- a/backend/rollup.config.ts +++ b/backend/rollup.config.ts @@ -38,30 +38,6 @@ const ANDROID_OUT_MAIN = const IOS_OUT = process.env.OUTPUT_DIR_IOS ?? path.join(__dirname, "dist/ios"); -/** - * `BENCH=1` switches this config to emit the bench-only bundle - * (`index.bench.js`) into the bench-specific output trees: - * - `android/src/bench/assets/nodejs-project/` (selected by the - * module's `android/build.gradle` when the `comapeoBench` Gradle - * property is set — this replaced the earlier productFlavor - * approach to dodge AGP variant ambiguity) - * - `ios/nodejs-project-bench/` (picked up by `ComapeoCore.podspec` - * iff `ENV['COMAPEO_BENCH']` is set at pod install time; the - * podspec also stages a copy at `ios/.bench-staging/nodejs-project/` - * so CocoaPods rsyncs it on top of the production bundle) - * - * Default (no env var) is unchanged: production `index.js` to the - * existing main/debug/iOS paths. - */ -const IS_BENCH = process.env.BENCH === "1"; - -const ANDROID_BENCH_OUT = - process.env.OUTPUT_DIR_ANDROID_BENCH ?? - path.join(__dirname, "dist/android/bench"); - -const IOS_BENCH_OUT = - process.env.OUTPUT_DIR_IOS_BENCH ?? path.join(__dirname, "dist/ios/bench"); - /** * Resolves `@comapeo/core`'s `./fastify-plugins/maps.js` import to the * iOS-only no-op stub. Scoped tightly to the `@comapeo/core/src/` importer @@ -111,15 +87,6 @@ const STATIC_ASSET_PATHS = [ "node_modules/@comapeo/fallback-smp", ] as const; -/** - * Bench-bundle static assets. The bench backend doesn't import - * `@comapeo/core` so none of the production runtime data files - * (drizzle SQL, default-categories zip, fallback map) are reachable - * from `index.bench.js`. Only the `package.json` is needed — Node's - * module resolver reads it to set the unpacked tree's module type. - */ -const BENCH_STATIC_ASSET_PATHS = ["package.json"] as const; - /** * Copies the static asset paths from `backend/` into `outDir` after the * rollup write completes. Replaces the per-platform staging copy that @@ -144,14 +111,10 @@ function buildPlugins({ platform, outDir, shouldMinify, - staticAssetPaths, - isBench, }: { platform: "android" | "ios"; outDir: string; shouldMinify: boolean; - staticAssetPaths: readonly string[]; - isBench: boolean; }): Plugin[] { return [ alias({ @@ -165,9 +128,8 @@ function buildPlugins({ ], }), // iOS-only: stub the maps fastify plugin so undici stays out of the - // bundle. See lib/maps-stub.js. Bench bundle doesn't import - // `@comapeo/core` at all, so no stub is needed. - ...(platform === "ios" && !isBench ? [stubComapeoMapsPlugin()] : []), + // bundle. See lib/maps-stub.js. + ...(platform === "ios" ? [stubComapeoMapsPlugin()] : []), // 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 @@ -182,7 +144,7 @@ function buildPlugins({ // @ts-expect-error Types for these rollup plugins are misconfigured: https://github.com/rollup/plugins/issues/1860 json(), shouldMinify ? minify() : undefined, - copyStaticAssetsPlugin(outDir, staticAssetPaths), + copyStaticAssetsPlugin(outDir, STATIC_ASSET_PATHS), ]; } @@ -205,10 +167,6 @@ const prodInput = { index: path.join(__dirname, "index.js"), }; -const benchInput = { - index: path.join(__dirname, "index.bench.js"), -}; - const sharedOutput: OutputOptions = { format: "esm", sourcemap: true, @@ -216,22 +174,13 @@ const sharedOutput: OutputOptions = { }; /** - * Production: three outputs from the same source tree (Android debug, - * Android release, iOS). Android gets the full bundle — its - * nodejs-mobile build permits JIT, so undici (and therefore the maps - * fastify plugin) loads cleanly. iOS gets the same bundle but with - * `@comapeo/core`'s maps plugin swapped for a no-op (see - * lib/maps-stub.js) because nodejs-mobile iOS runs V8 with `--jitless` - * and undici's WebAssembly init would crash module load. - * - * Bench: a separate two-output mode keyed off `BENCH=1`. Same banner / - * loader machinery so the native addon system works identically, but - * the entry is `index.bench.js` (which doesn't import `@comapeo/core`) - * and the static-asset copy is trimmed to just `package.json`. Bench - * outputs land in flavor-specific paths (`android/src/bench/...`, - * `ios/nodejs-project-bench/`) that production consumers never see; - * see android/build.gradle and ios/ComapeoCore.podspec for the - * consumer-side wiring. + * Three outputs from the same source tree (Android debug, Android + * release, iOS). Android gets the full bundle — its nodejs-mobile + * build permits JIT, so undici (and therefore the maps fastify plugin) + * loads cleanly. iOS gets the same bundle but with `@comapeo/core`'s + * maps plugin swapped for a no-op (see lib/maps-stub.js) because + * nodejs-mobile iOS runs V8 with `--jitless` and undici's WebAssembly + * init would crash module load. * * Each output's `banner` defines `__loadAddon(name, version)` with the * platform-appropriate `process.dlopen` target — Android does @@ -239,7 +188,7 @@ const sharedOutput: OutputOptions = { * Embed-&-Sign'd xcframework binary at NATIVE_LIB_DIR/.framework/. * See `rollup-plugin-addon-loader.js` for the helper bodies. */ -const prodConfig: RollupOptions[] = [ +const config: RollupOptions[] = [ { input: prodInput, output: { @@ -254,8 +203,6 @@ const prodConfig: RollupOptions[] = [ outDir: ANDROID_OUT_DEBUG, // Android debug does not minify the bundle. shouldMinify: false, - staticAssetPaths: STATIC_ASSET_PATHS, - isBench: false, }), ], }, @@ -272,8 +219,6 @@ const prodConfig: RollupOptions[] = [ platform: "android", outDir: ANDROID_OUT_MAIN, shouldMinify: true, - staticAssetPaths: STATIC_ASSET_PATHS, - isBench: false, }), ], }, @@ -290,50 +235,9 @@ const prodConfig: RollupOptions[] = [ platform: "ios", outDir: IOS_OUT, shouldMinify: true, - staticAssetPaths: STATIC_ASSET_PATHS, - isBench: false, - }), - ], - }, -]; - -const benchConfig: RollupOptions[] = [ - { - input: benchInput, - output: { - ...sharedOutput, - dir: ANDROID_BENCH_OUT, - banner: androidAddonLoaderBanner, - }, - plugins: [ - cleanOutputDirPlugin(ANDROID_BENCH_OUT), - ...buildPlugins({ - platform: "android", - outDir: ANDROID_BENCH_OUT, - shouldMinify: true, - staticAssetPaths: BENCH_STATIC_ASSET_PATHS, - isBench: true, - }), - ], - }, - { - input: benchInput, - output: { - ...sharedOutput, - dir: IOS_BENCH_OUT, - banner: iosAddonLoaderBanner, - }, - plugins: [ - cleanOutputDirPlugin(IOS_BENCH_OUT), - ...buildPlugins({ - platform: "ios", - outDir: IOS_BENCH_OUT, - shouldMinify: true, - staticAssetPaths: BENCH_STATIC_ASSET_PATHS, - isBench: true, }), ], }, ]; -export default IS_BENCH ? benchConfig : prodConfig; +export default config; diff --git a/scripts/build-backend.ts b/scripts/build-backend.ts index 6169ed4..2ba4134 100755 --- a/scripts/build-backend.ts +++ b/scripts/build-backend.ts @@ -10,20 +10,6 @@ import { packageAndroidJniLibs } from "./lib/android-jni.ts"; import { packageIosFrameworks } from "./lib/ios-frameworks.ts"; import { audit16kAlignment } from "./lib/check-16k-alignment.ts"; -// ------------------------------------------------ -// Mode -// ------------------------------------------------ - -// `--bench` produces the bench-only JS bundle into bench-specific -// sibling paths (`android/src/bench/assets/nodejs-project/` and -// `ios/nodejs-project-bench/`). Native binaries (JNI .so files, -// per-addon xcframeworks, libnode) are NOT re-packaged in bench mode — -// the bench bundle has no native-addon imports, and the production -// build run owns the binaries that `apps/benchmark/` will share at -// install time. Run `npm run backend:build` first if you haven't, then -// `npm run backend:build -- --bench` to produce the bench bundle. -const IS_BENCH = process.argv.slice(2).includes("--bench"); - // ------------------------------------------------ // Paths // ------------------------------------------------ @@ -43,25 +29,9 @@ const ANDROID_MAIN_NODEJS_PROJECT_DIR = join( PROJECT_ROOT, "android/src/main/assets/nodejs-project", ); -// Bench bundle output. Lives under `src/bench/assets/` so the module's -// `android/build.gradle` only swaps it in when the consuming app sets -// `comapeoBench=true` in its `gradle.properties` (a project property, -// not a productFlavor — the flavor approach hit AGP / Gradle 9 strict -// variant resolution issues for Expo apps without matching flavors). -// `apps/benchmark/`'s `with-comapeo-bench` config plugin sets it; -// `apps/example/` and third-party apps don't. -const ANDROID_BENCH_NODEJS_PROJECT_DIR = join( - PROJECT_ROOT, - "android/src/bench/assets/nodejs-project", -); const ANDROID_JNILIBS_DIR = join(PROJECT_ROOT, "android/src/main/jniLibs"); const ANDROID_LIBNODE_DIR = join(PROJECT_ROOT, "android/libnode/bin"); const IOS_NODEJS_PROJECT_DIR = join(PROJECT_ROOT, "ios/nodejs-project"); -// Bench-only resource bundle. Picked up by `ComapeoCore.podspec` and -// renamed to `nodejs-project/` in the embedded app bundle when -// `ENV['COMAPEO_BENCH']` is set at pod install (the `with-comapeo-bench` -// config plugin in `apps/benchmark/` sets it). -const IOS_BENCH_NODEJS_PROJECT_DIR = join(PROJECT_ROOT, "ios/nodejs-project-bench"); // One xcframework per native module instance. CocoaPods picks them up // via `vendored_frameworks` in ComapeoCore.podspec; Xcode's standard // Embed & Sign phase places + codesigns them into .app/Frameworks/ @@ -79,33 +49,6 @@ const IOS_FRAMEWORKS_WORK_DIR = join(SCRATCH_DIR, "frameworks"); // Pipeline // ------------------------------------------------ -if (IS_BENCH) { - // Bench mode: rollup only. The bench bundle has no native-addon - // imports, so no prebuilds / JNI / xcframework packaging is needed — - // the production build run already laid those down. We bundle into - // bench-specific output dirs (`android/src/bench/assets/` and - // `ios/nodejs-project-bench/`) which are activated by the - // `comapeoBench=true` Gradle property on Android / `ENV['COMAPEO_BENCH']` - // iOS env var (consumed by `ios/ComapeoCore.podspec`). - await $({ - cwd: BACKEND_SRC_DIR, - stdio: "inherit", - env: { - ...process.env, - BENCH: "1", - OUTPUT_DIR_ANDROID_BENCH: ANDROID_BENCH_NODEJS_PROJECT_DIR, - OUTPUT_DIR_IOS_BENCH: IOS_BENCH_NODEJS_PROJECT_DIR, - }, - })`npm run build`; - - console.log( - `Bench bundle written to:\n ${ANDROID_BENCH_NODEJS_PROJECT_DIR}\n ${IOS_BENCH_NODEJS_PROJECT_DIR}`, - ); - - // Skip the rest of the pipeline — production binaries cover bench too. - process.exit(0); -} - rmSync(SCRATCH_DIR, { force: true, recursive: true }); // 1. Native module ABI is read from the libnode header laid down by From 634b3db75d2193d49ab791b373a9abbea30ac3b0 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 12:57:41 +0100 Subject: [PATCH 08/33] refactor(module): drop bench-specific build wiring With the bench app moved to apps/benchmark/ and using the new comapeoBackendDir override hook, the production module no longer needs: - comapeoBench Gradle property + conditional sourceSet swap in android/build.gradle (sourceSets revert to AGP defaults) - ENV['COMAPEO_BENCH'] branch + .bench-staging rsync in ios/ComapeoCore.podspec (s.resources is just ['nodejs-project']) - !android/src/bench/ and !ios/nodejs-project-bench/ exclusions in package.json files (those dirs no longer exist in the module) - Bench-specific .gitignore entries Also removes the (build-artifact, gitignored) android/src/bench/ and ios/nodejs-project-bench/ directories, and updates two stale comments in retained source files plus a header note in the planning doc pointing at the v2 implementation in apps/benchmark/. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 12 ------ android/build.gradle | 48 +++--------------------- apps/benchmark/backend/lib/boot-spans.js | 8 ++-- backend/lib/message-port.js | 2 +- docs/uds-rpc-bridge-benchmark-plan.md | 15 ++++++++ ios/ComapeoCore.podspec | 39 +------------------ package.json | 4 +- 7 files changed, 28 insertions(+), 100 deletions(-) diff --git a/.gitignore b/.gitignore index e53bb9a..cdfe0ae 100644 --- a/.gitignore +++ b/.gitignore @@ -69,24 +69,12 @@ ios/.swiftpm/ # ABI into jniLibs/. # * iOS: rolled-up JS bundle into ios/nodejs-project/ + per-addon # xcframework into ios/Frameworks/. -# * Bench-only sibling outputs (npm run backend:build -- --bench) -# land in `android/src/bench/assets/` and `ios/nodejs-project-bench/`; -# activated on Android by the `comapeoBench` Gradle property -# (set in the consuming app's `gradle.properties`), and on iOS by -# the `ENV['COMAPEO_BENCH']` env var read by the podspec at pod -# install time. The podspec also stages a copy of -# `ios/nodejs-project-bench/` to `ios/.bench-staging/nodejs-project/` -# so CocoaPods can rsync it onto the production bundle in the app -# resources without an Xcode Run Script phase. backend/dist nodejs-assets android/src/debug/assets/ android/src/main/assets/ android/src/main/jniLibs/ -android/src/bench/assets/ ios/nodejs-project/ -ios/nodejs-project-bench/ -ios/.bench-staging/ ios/Frameworks/ # output diff --git a/android/build.gradle b/android/build.gradle index 5055abb..8a4ed30 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -42,34 +42,16 @@ def comapeoAbiFilters = { return effective }() -// `comapeoBench=true` (set in the consuming app's gradle.properties) -// opts the consuming AAR/APK into the benchmark JS bundle -// (`backend/index.bench.js`, rolled up into -// `src/bench/assets/nodejs-project/`). The bench sourceSet's -// nodejs-project files overlay main's at AGP merge time — bench's -// `index.mjs` replaces production's at the same relative path. Default -// consumers (`apps/example/`, third-party apps) do not set the -// property and never include `src/bench/`. -// -// Implemented as a project property rather than a productFlavor to -// avoid the AGP variant-attribute ambiguity that flavor dimensions -// trigger for Expo apps that don't declare matching flavors of their -// own (AGP 8.x + Gradle 9 strict variant resolution). The -// `with-comapeo-bench` Expo config plugin in `apps/benchmark/` flips -// this property by writing it into the consuming app's -// `android/gradle.properties`. -def comapeoBenchEnabled = (rootProject.findProperty('comapeoBench') ?: project.findProperty('comapeoBench'))?.toString() == 'true' - -// Generic override for the assets subdirectory the loader reads -// `index.mjs` from. Defaults to `nodejs-project` (the production -// bundle path emitted by `scripts/build-backend.ts`). A consumer that -// ships its own backend bundle in a sibling assets directory (e.g. +// Override for the assets subdirectory the loader reads `index.mjs` +// from. Defaults to `nodejs-project` (the production bundle path +// emitted by `scripts/build-backend.ts`). A consumer that ships its +// own backend bundle in a sibling assets directory (e.g. // `nodejs-bench/`) sets `comapeoBackendDir=nodejs-bench` in its // `android/gradle.properties` and supplies the directory at // `app/src/main/assets/nodejs-bench/` itself; AGP's normal asset merge // places it alongside `nodejs-project/` in the APK and the loader -// reads the override path. Surface is intentionally generic so it -// isn't bench-specific. +// reads the override path. The bench app at `apps/benchmark/` uses +// this hook via its `with-comapeo-bench` config plugin. def comapeoBackendDir = (rootProject.findProperty('comapeoBackendDir') ?: project.findProperty('comapeoBackendDir') ?: 'nodejs-project').toString() def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") @@ -134,24 +116,6 @@ android { // native module instance × ABI). Both directories ship into the // same APK `lib//` segment. jniLibs.srcDirs 'libnode/bin/', 'src/main/jniLibs/' - // When `comapeoBench=true`, swap `src/main/assets` for - // `src/bench/assets`. Assigning rather than calling — - // `srcDirs '<...>'` is additive (AGP keeps the default - // `src/main/assets`); assignment replaces. - // - // AGP rejects duplicate relative paths across srcDirs, so - // we replace rather than overlay. - assets.srcDirs = comapeoBenchEnabled - ? ['src/bench/assets'] - : ['src/main/assets'] - } - // The default `debug` sourceSet picks up `src/debug/assets` - // (where `scripts/build-backend.ts` writes the unminified - // production bundle for debug builds). When bench is active, - // empty it out so the prod debug bundle doesn't overlay our - // bench bundle and end up shipping in the bench APK. - debug { - assets.srcDirs = comapeoBenchEnabled ? [] : ['src/debug/assets'] } } externalNativeBuild { diff --git a/apps/benchmark/backend/lib/boot-spans.js b/apps/benchmark/backend/lib/boot-spans.js index f16f919..5c4e0e5 100644 --- a/apps/benchmark/backend/lib/boot-spans.js +++ b/apps/benchmark/backend/lib/boot-spans.js @@ -2,10 +2,10 @@ import { startSpan } from "./telemetry-sink.js"; /** * Names of every boot phase the Sentry plan §7.4.2 enumerates. Re-used - * by the bench backend (see `backend/index.bench.js`) and intended to - * be picked up by the production `backend/index.js` when Sentry plan - * Phase 3 lands. Keeping the names identical means the same dashboards - * work for both transports. + * by the bench backend (see `apps/benchmark/backend/index.js`) and + * intended to be picked up by the production `backend/index.js` when + * Sentry plan Phase 3 lands. Keeping the names identical means the + * same dashboards work for both transports. * * Three of the six are server-side (Node) and three are native-side: * diff --git a/backend/lib/message-port.js b/backend/lib/message-port.js index 26a6267..4198c13 100644 --- a/backend/lib/message-port.js +++ b/backend/lib/message-port.js @@ -90,7 +90,7 @@ export class SocketMessagePort extends TypedEmitter { // socket has ended end up throwing past this check; the host // process's `uncaughtException` handler must filter the resulting // `ERR_STREAM_WRITE_AFTER_END` during shutdown — see - // `backend/index.bench.js`. + // `apps/benchmark/backend/index.js` for an example filter. if (this.#state === "closed") return; this.#framedStream.write(Buffer.from(JSON.stringify(message))); } diff --git a/docs/uds-rpc-bridge-benchmark-plan.md b/docs/uds-rpc-bridge-benchmark-plan.md index cb756db..bd42234 100644 --- a/docs/uds-rpc-bridge-benchmark-plan.md +++ b/docs/uds-rpc-bridge-benchmark-plan.md @@ -1,5 +1,20 @@ # UDS / RPC Bridge Benchmark Suite +> **Note (2026-05-05):** this document captures the original v1 +> implementation plan, in which the bench bundle and rollup config +> lived inside the production module behind a `comapeoBench=true` +> Gradle toggle / `ENV['COMAPEO_BENCH']` Podfile mutation. The +> benchmark has since been refactored: the bench backend, rollup +> config, and Expo config plugin moved to `apps/benchmark/`, and the +> module exposes a generic `comapeoBackendDir` override (Gradle +> property → `BuildConfig.COMAPEO_BACKEND_DIR` on Android, +> `ComapeoBackendDir` Info.plist key on iOS) that the bench app's +> plugin sets. Goals (boot phases, RPC sweeps, sinks, Maestro flows) +> are unchanged; only the build wiring differs. For current behavior +> see `apps/benchmark/backend/`, `apps/benchmark/plugins/with-comapeo-bench/`, +> and the `comapeoBackendDir` blocks in `android/build.gradle` and +> `ios/AppLifecycleDelegate.swift`. + ## Context `@comapeo/core-react-native` connects React Native to a `nodejs-mobile` diff --git a/ios/ComapeoCore.podspec b/ios/ComapeoCore.podspec index 3ee437c..b06e964 100644 --- a/ios/ComapeoCore.podspec +++ b/ios/ComapeoCore.podspec @@ -1,5 +1,4 @@ require 'json' -require 'fileutils' package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) @@ -46,41 +45,5 @@ Pod::Spec.new do |s| # of truth lives under `backend/` at the repo root. Native `.node` files # ship separately via `Frameworks/*.xcframework` (above) and are embedded # + codesigned by Xcode automatically. - # - # Bench opt-in: when `ENV['COMAPEO_BENCH']` is set at `pod install` time - # AND the bench bundle (`nodejs-project-bench/`, produced by - # `scripts/build-backend.ts --bench`) is on disk, this podspec stages - # the bench bundle to `.bench-staging/nodejs-project/` and declares it - # in `s.resources` ALONGSIDE the production `nodejs-project/`. - # CocoaPods' `[CP] Copy Pods Resources` rsyncs both into the app - # bundle in declaration order, with the same destination basename, so - # the bench bundle's `index.mjs` overlays the production bundle's - # without changing the path the native loader looks at. This avoids - # the build-phase ordering footgun that an Xcode Run Script approach - # hit (CocoaPods 1.x can't reliably position a user script phase - # after `[CP] Copy Pods Resources`); the staging here happens at - # podspec evaluation time, before any Xcode build phase runs. - # - # Robustness: if the bench bundle is missing (forgot to run - # `--bench`), the staging step is skipped and only the production - # bundle ships. The bench app boots production rather than crashing - # on a missing resource — graceful degradation. Default consumers - # (`apps/example/`, third parties) leave the env var unset and ship - # exactly today's production `nodejs-project/` byte-identically. - resources = ['nodejs-project'] - if ENV['COMAPEO_BENCH'] == '1' - bench_src = File.join(__dir__, 'nodejs-project-bench') - if File.directory?(bench_src) - bench_staged = File.join(__dir__, '.bench-staging', 'nodejs-project') - FileUtils.rm_rf(bench_staged) - FileUtils.mkdir_p(File.dirname(bench_staged)) - FileUtils.cp_r(bench_src, bench_staged) - # Order matters: `nodejs-project` first so the bench overlay - # rsyncs over it. (CP iterates the list in declaration order; - # rsync overlay leaves prod-only files like drizzle SQL intact - # but unused — the bench `index.mjs` is what the loader reads.) - resources << '.bench-staging/nodejs-project' - end - end - s.resources = resources + s.resources = ['nodejs-project'] end diff --git a/package.json b/package.json index 5cce035..821c212 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,7 @@ "android/libnode/", "build/", "ios/", - "src/", - "!android/src/bench/", - "!ios/nodejs-project-bench/" + "src/" ], "devEngines": { "packageManager": { From c1bd18807ce708f239e81092389562d4c8b99ae4 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 12:58:33 +0100 Subject: [PATCH 09/33] refactor(api): rename benchMessagePort to unstable_messagePort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export was always misnamed: it isn't a benchmark-specific API, it's the raw `MessagePort`-shaped escape hatch one level below the `comapeo` client. Anything paired with a custom backend bundle (the bench app being the canonical example) goes through this port. `unstable_` matches React's `unstable_batchedUpdates` / `unstable_setExceptionDecorator` convention — signals "may change without notice" without burning the API on a name like `INTERNAL_messagePort` that implies stronger guarantees about internal-only access. Lowercase because it's an instance, not a class. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/benchmark/App.tsx | 6 +++--- src/ComapeoCoreModule.ts | 27 ++++++++++++++------------- src/index.ts | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/benchmark/App.tsx b/apps/benchmark/App.tsx index 4fd2f83..3a0ae6a 100644 --- a/apps/benchmark/App.tsx +++ b/apps/benchmark/App.tsx @@ -1,5 +1,5 @@ import { - benchMessagePort, + unstable_messagePort, state, type ComapeoState, } from "@comapeo/core-react-native"; @@ -87,7 +87,7 @@ class BenchClient { ensureListener() { if (this.listenerInstalled) return; this.listenerInstalled = true; - benchMessagePort.addListener("message", (msg) => { + unstable_messagePort.addListener("message", (msg) => { if ( !msg || typeof msg !== "object" || @@ -125,7 +125,7 @@ class BenchClient { (timer as unknown as { unref: () => void }).unref(); } this.pending.set(id, { resolve, timer }); - benchMessagePort.postMessage({ id, method, params } as never); + unstable_messagePort.postMessage({ id, method, params } as never); }); } } diff --git a/src/ComapeoCoreModule.ts b/src/ComapeoCoreModule.ts index 0ca2e7b..b7c850b 100644 --- a/src/ComapeoCoreModule.ts +++ b/src/ComapeoCoreModule.ts @@ -73,22 +73,23 @@ const messagePort = corePort as unknown as MessagePort; export const comapeo: MapeoClientApi = createMapeoClient(messagePort); /** - * Raw `CoreMessagePort` singleton, exported for the benchmark app - * (`apps/benchmark/`) to bypass the `MapeoClient` request/response - * machinery and speak directly to the bench backend's `BenchRpcServer` - * (which uses a different wire schema — see - * `backend/lib/bench-rpc.js`). Production consumers should use the - * `comapeo` export above; this is a deliberate escape hatch for the - * UDS/RPC bridge benchmark suite (`docs/uds-rpc-bridge-benchmark-plan.md`) - * and ships in the same module surface so the bench app doesn't need - * a private import path. + * Raw `CoreMessagePort` singleton — escape hatch for consumers that + * need to bypass the `MapeoClient` request/response machinery and + * speak directly to whatever backend bundle the consumer has wired in + * (e.g. the bench app at `apps/benchmark/` overrides the backend via + * `comapeoBackendDir` and talks to its own `BenchRpcServer`). + * + * `unstable_` prefix follows the React/RN convention for APIs whose + * shape may change without notice — this port exposes the framing + * layer one level below the public `comapeo` client and isn't part of + * the supported surface. Production consumers should use `comapeo`. * * Note: `createMapeoClient(messagePort)` above already adds a - * `"message"` listener to this port. Bench requests use a different - * `{id, method, params}` shape so the prod RPC machinery treats them - * as unknown frames and silently ignores them. + * `"message"` listener to this port. Custom requests with a different + * shape than `{ id, method, params }` are treated as unknown frames + * by the prod RPC machinery and silently ignored. */ -export const benchMessagePort = corePort; +export const unstable_messagePort = corePort; type StateEvents = { stateChange: (state: ComapeoState, error: ComapeoErrorInfo | null) => void; diff --git a/src/index.ts b/src/index.ts index c243fcf..2d4acd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ // Reexport the native module. On web, it will be resolved to ComapeoCoreModule.web.ts // and on native platforms to ComapeoCoreModule.ts -export { comapeo, state, benchMessagePort } from "./ComapeoCoreModule"; +export { comapeo, state, unstable_messagePort } from "./ComapeoCoreModule"; export * from "./ComapeoCore.types"; From d6ff5f6852c67bd30580c2da483cb06bd1621f67 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 13:13:51 +0100 Subject: [PATCH 10/33] refactor(backend): revert rollup.config.ts to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier-in-branch edits parameterized `copyStaticAssetsPlugin` and renamed `sharedInput → prodInput` to support a `BENCH=1` mode that was since deleted. With the bench bundle owning its own rollup config in apps/benchmark/, none of those scaffolding changes are needed — restoring the file to main reduces the diff and keeps the production config minimal. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/rollup.config.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/backend/rollup.config.ts b/backend/rollup.config.ts index 833add7..61e9667 100644 --- a/backend/rollup.config.ts +++ b/backend/rollup.config.ts @@ -92,12 +92,12 @@ const STATIC_ASSET_PATHS = [ * rollup write completes. Replaces the per-platform staging copy that * `scripts/build-backend.ts` used to do. */ -function copyStaticAssetsPlugin(outDir: string, paths: readonly string[]): Plugin { +function copyStaticAssetsPlugin(outDir: string): Plugin { return { name: "copy-static-assets", async writeBundle() { await Promise.all( - paths.map((rel) => + STATIC_ASSET_PATHS.map((rel) => cp(path.join(__dirname, rel), path.join(outDir, rel), { recursive: true, }), @@ -144,7 +144,7 @@ function buildPlugins({ // @ts-expect-error Types for these rollup plugins are misconfigured: https://github.com/rollup/plugins/issues/1860 json(), shouldMinify ? minify() : undefined, - copyStaticAssetsPlugin(outDir, STATIC_ASSET_PATHS), + copyStaticAssetsPlugin(outDir), ]; } @@ -163,7 +163,7 @@ function cleanOutputDirPlugin(dir: string): Plugin { }; } -const prodInput = { +const sharedInput = { index: path.join(__dirname, "index.js"), }; @@ -174,13 +174,12 @@ const sharedOutput: OutputOptions = { }; /** - * Three outputs from the same source tree (Android debug, Android - * release, iOS). Android gets the full bundle — its nodejs-mobile - * build permits JIT, so undici (and therefore the maps fastify plugin) - * loads cleanly. iOS gets the same bundle but with `@comapeo/core`'s - * maps plugin swapped for a no-op (see lib/maps-stub.js) because - * nodejs-mobile iOS runs V8 with `--jitless` and undici's WebAssembly - * init would crash module load. + * Three outputs from the same source tree: Android debug, Android release, and iOS. + * Android gets the full bundle — its nodejs-mobile build permits JIT, so undici + * (and therefore the maps fastify plugin) loads cleanly. iOS gets the same bundle but with + * `@comapeo/core`'s maps plugin swapped for a no-op (see lib/maps-stub.js) + * because nodejs-mobile iOS runs V8 with `--jitless` and undici's + * WebAssembly init would crash module load. * * Each output's `banner` defines `__loadAddon(name, version)` with the * platform-appropriate `process.dlopen` target — Android does @@ -190,7 +189,7 @@ const sharedOutput: OutputOptions = { */ const config: RollupOptions[] = [ { - input: prodInput, + input: sharedInput, output: { ...sharedOutput, dir: ANDROID_OUT_DEBUG, @@ -207,7 +206,7 @@ const config: RollupOptions[] = [ ], }, { - input: prodInput, + input: sharedInput, output: { ...sharedOutput, dir: ANDROID_OUT_MAIN, @@ -223,7 +222,7 @@ const config: RollupOptions[] = [ ], }, { - input: prodInput, + input: sharedInput, output: { ...sharedOutput, dir: IOS_OUT, From aa56b90b213b1273eb8419b8595d0a090bad32fc Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 13:13:59 +0100 Subject: [PATCH 11/33] docs: drop sentry-integration-plan.md from this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sentry plan was committed here only because this branch was originally cut off the sentry-plan tip (fd33ffc) so the bench design could reference it during planning. Now that the bench refactor is self-contained, the doc shouldn't ship via this PR — it'll land on main from the dedicated Sentry branch instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/sentry-integration-plan.md | 1544 ------------------------------- 1 file changed, 1544 deletions(-) delete mode 100644 docs/sentry-integration-plan.md diff --git a/docs/sentry-integration-plan.md b/docs/sentry-integration-plan.md deleted file mode 100644 index 952eebb..0000000 --- a/docs/sentry-integration-plan.md +++ /dev/null @@ -1,1544 +0,0 @@ -# Sentry Integration Plan - -How we propose to wire Sentry error reporting and RPC tracing into -`@comapeo/core-react-native` without forcing every consumer of this -module to ship Sentry. The integration is **opt-in and host-app -driven** so that only the CoMapeo Mobile app pays the bundle cost, -sends events to a DSN, and sees its data in Sentry — other apps that -depend on this module continue to ship with no Sentry traffic and no -Sentry binaries. - -Companion docs: -- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — process model, IPC, lifecycle. -- Reference implementation in CoMapeo Mobile: - [`comapeo-mobile/src/backend/src/app.js`](https://github.com/digidem/comapeo-mobile/blob/develop/src/backend/src/app.js). -- Upstream OpenTelemetry instrumentation in `@comapeo/core`: - [`comapeo-core PR #1051`](https://github.com/digidem/comapeo-core/pull/1051). - ---- - -## 1. Goals & non-goals - -### Goals - -1. **Capture errors** raised at every layer the module owns: - - Node backend: `uncaughtException`, `unhandledRejection`, boot - phase failures (`listen-control`, `init`, `construct`, - `runtime`), per-RPC throws. - - RN/JS layer: `state` ERROR transitions, `messageerror` protocol - parse failures, RPC client rejections. - - Native: rootkey load failures, watchdog timeouts, IPC - connection errors, hard process crashes (Android FGS, - iOS in-process). -2. **Trace RPC calls** end-to-end across the React Native ↔ Node - boundary, mirroring the `onRequestHook` pattern used in - `comapeo-mobile/src/backend/src/app.js`. Each RPC call appears - as a transaction whose parent span is the JS-side caller. -3. **Forward OpenTelemetry spans** emitted by `@comapeo/core` (once - PR #1051 lands) to Sentry without bundle-time coupling to a - specific exporter. -4. **App-specific gating**: zero Sentry traffic, zero Sentry SDK - activation, and ideally zero meaningful bundle delta for any - consumer that doesn't opt in. - -### Non-goals - -- We are not adding a generic telemetry abstraction. The module - speaks Sentry-shaped APIs (DSN, `Sentry.captureException`, - OpenTelemetry-compatible spans). Other backends are out of scope. -- We are not capturing user-PII or message contents. Spans get - method names and structural metadata, not arguments. -- We are not auto-installing Sentry SDKs on the host app's behalf. - The host app declares the dependency; the module just wires it in. - ---- - -## 2. Why "app-specific" matters here - -`@comapeo/core-react-native` is a library. It has at least two -different consumers expected over time (the CoMapeo Mobile app, and -the in-tree `apps/example` integration harness — and potentially -third-party apps building on the module). We cannot: - -- **Bundle a hard dependency on `@sentry/node` into the published - Node backend.** That bundle is staged into - `android/src/{debug,main}/assets/nodejs-project/` and - `ios/nodejs-project/` at `npm run backend:build` time - (see `backend/rollup.config.ts` and - `scripts/build-backend.ts`). Whatever ends up in the rollup is on - every consumer's device, regardless of whether they want Sentry. -- **Hard-import `@sentry/react-native` from `src/`.** Doing so - would force every consumer to install it, and any consumer that - does not call `Sentry.init()` would still get a runtime warning - from the module attempting to use an uninitialized client. -- **Ship a DSN.** The DSN is per-app secret (well, per-app config). - It belongs in the host app's environment, not in the published - module's source. - -The integration must therefore be: - -1. **Inert by default.** Module installed but not configured → no - Sentry calls, no SDK init, no trace metadata on RPC frames. -2. **Activated by the host app.** A single configuration entry - point, called from the host app's startup code, switches - instrumentation on with a DSN, environment, release, sample - rates, etc. -3. **Reachable from all three layers.** The same call from JS must - propagate to the Node backend (so it can `Sentry.init()` and - register `onRequestHook`) and to native (so iOS/Android crash - reporters can be enabled). - ---- - -## 3. Layered architecture - -There are three independent Sentry scopes to manage. They share a -DSN and a release tag, but each runs in its own process / runtime -and needs its own SDK init. - -``` -┌──────────────────────────── Host app ─────────────────────────────┐ -│ │ -│ ┌─────────────── React Native (JS) ────────────────┐ │ -│ │ @sentry/react-native │ │ -│ │ - JS errors, native crashes (iOS+Android) │ │ -│ │ - starts trace for RPC calls │ │ -│ │ │ │ -│ │ @comapeo/core-react-native: │ │ -│ │ - state.on('stateChange', ERROR) → captureException │ -│ │ - state.on('messageerror', ...) → captureException │ -│ │ - comapeo.() wrapper: startSpan + │ │ -│ │ attach sentry-trace + baggage in metadata │ │ -│ └──────────────────────────────────────────────────┘ │ -│ │ │ -│ │ control.sock {type:"init",sentry:…} │ -│ │ comapeo.sock RPC (with sentry-trace) │ -│ ▼ │ -│ ┌─────────────────── Node backend ─────────────────┐ │ -│ │ @sentry/node (bundled, init only on opt-in) │ │ -│ │ - handleFatal → captureException │ │ -│ │ - createMapeoServer({ onRequestHook }) → spans │ │ -│ │ - OpenTelemetry processor sends @comapeo/core │ │ -│ │ spans (PR #1051) to Sentry transport │ │ -│ └──────────────────────────────────────────────────┘ │ -│ │ │ -│ │ shared DSN/release/env │ -│ ▼ │ -│ ┌─────────────────── Native (FGS) ─────────────────┐ │ -│ │ Android: sentry-android via @sentry/react-native│ │ -│ │ iOS: sentry-cocoa via @sentry/react-native │ │ -│ │ - hard crash reports │ │ -│ │ - we forward NodeJSService ERROR transitions │ │ -│ │ with phase tag for correlation │ │ -│ └──────────────────────────────────────────────────┘ │ -└───────────────────────────────────────────────────────────────────┘ -``` - -Key splits: - -- **JS and native** share a single `@sentry/react-native` SDK that - the host app installs and initializes. The module never imports - `@sentry/react-native` directly; it accepts a Sentry-shaped - adapter object that the host hands in (see §4.1). -- **Node backend** runs a separate `@sentry/node` SDK, initialized - inside the bundle. Configuration is read at native process start - from build-time-baked sources (Android manifest meta-data, iOS - Info.plist) seeded by an Expo config plugin (§4.2), and forwarded - to the backend in the existing `init` control-socket frame. This - avoids any JS round-trip on the boot path so the FGS can - cold-start without RN being alive. -- **Android FGS process** has no JS bridge but does reach the - same Sentry-android SDK if the host app's `MainApplication` - initializes it before starting the FGS. Cross-process attribution - is via `release`+`environment`+a `proc:fgs` tag, not a shared - client. - ---- - -## 4. Configuration - -### 4.0 The cold-start constraint - -Earlier drafts of this plan plumbed the backend Sentry config from -JS through the control-socket `init` frame. That has a real cost: - -1. **FGS cold-start (Android).** The `:ComapeoCore` foreground - service can be cold-launched by the system to deliver a sync - trigger *before* the host app's RN bridge is alive. With a - JS-driven config, the FGS would have to either start the - backend with Sentry off (losing observability for the most - interesting code path — boot-time errors during a cold sync) - or block on RN to come up first (defeats the purpose of an - FGS-survives-RN architecture). -2. **Boot latency on every launch.** Even when RN is alive, the - JS round-trip for `setSentryConfig(...)` adds a serial step - to the boot sequence. The backend can't sample `boot.listen` - or `boot.construct` spans until after RN is ready and has - called `configureSentry`. -3. **State observability gap.** `state.getState()` reflects only - transitions captured *after* the JS listener is attached. - Errors that fire before `configureSentry` runs (rootkey load - races, FGS-side watchdog timeouts) miss Sentry entirely under - the JS-driven model. - -Three configuration vectors solve this together: - -| Vector | When read | Purpose | -|---|---|---| -| **Expo config plugin** (build-time) | At native process start, before any IPC | DSN, environment, release, sample rates. The single source of truth. | -| **Persisted native preference** (runtime, restart-to-activate) | At native process start | The "capture application data" toggle (§9). | -| **JS adapter handoff** (`configureSentry`) | When RN bridge is up | Hands the host app's already-initialized `@sentry/react-native` to this module so JS-side listeners can call `captureException` / `startSpan`. Does **not** carry DSN. | - -### 4.1 Build-time: Expo config plugin (primary) - -A new plugin shipped from this module — `app.plugin.js` at the -package root, registered in `expo-module.config.json`. It uses -the same `@expo/config-plugins` patterns already in use for -`apps/example/plugins/with-android-tests/index.js`. - -Consumer registration in CoMapeo Mobile's `app.json` / -`app.config.ts`: - -```json -{ - "expo": { - "plugins": [ - [ - "@comapeo/core-react-native", - { - "sentry": { - "dsn": "https://abc@sentry.example.com/1", - "environment": "production", - "release": "1.4.2", - "tracesSampleRate": 0.1, - "rpcArgsBytes": 0 - } - } - ] - ] - } -} -``` - -The plugin runs at `expo prebuild` and writes: - -**Android — `` meta-data in `AndroidManifest.xml`** via -`withAndroidManifest`: - -```xml - - - - - -``` - -These meta-data live on the manifest's main `` tag so -**both the main process and the `:ComapeoCore` FGS process see -them** — `PackageManager.getApplicationInfo(...).metaData` is -shared across processes within the package. - -**iOS — keys in `Info.plist`** via `withInfoPlist`: - -```xml -ComapeoCoreSentryDsn -https://abc@sentry.example.com/1 -ComapeoCoreSentryEnvironment -production -ComapeoCoreSentryRelease -1.4.2 -ComapeoCoreSentryTracesSampleRate -0.1 -ComapeoCoreSentryRpcArgsBytes -0 -``` - -Plugin behaviour rules: - -- If the consumer registers the plugin without a `sentry` key, no - meta-data / Info.plist entries are written. Native treats the - absence as "Sentry off". The example app under `apps/example/` - ships unconfigured. -- If the consumer registers the plugin **with** a `sentry` key, - exactly the keys provided are written. Missing optional fields - (e.g. `environment`) result in absent manifest entries, which - native maps to `null` in the loaded `SentryConfig`. -- Plugin code is small (~50 LOC) and lives alongside the existing - `app.plugin.js` patterns. The plugin is consumed at `expo - prebuild` time only — runtime code path doesn't touch it. -- The DSN is now embedded in the host app's APK/IPA. That's an - accepted tradeoff: Sentry DSNs are not high-secret values - (they identify a project, not authenticate writes; rate - limiting and per-project ingest are server-side). They appear - in stripped binaries of every Sentry-using app. - -### 4.2 Native config consumption - -At native process start (FGS `onCreate` on Android, app delegate -init on iOS), the module loads the manifest / plist keys into a -typed `SentryConfig?` and propagates it two ways: - -```kotlin -// android/.../SentryConfigStore.kt (new) — sketch -data class SentryConfig( - val dsn: String, - val environment: String?, - val release: String?, - val sampleRate: Double?, - val tracesSampleRate: Double?, - val rpcArgsBytes: Int?, -) - -fun loadFromManifest(ctx: Context): SentryConfig? { - val meta = ctx.packageManager.getApplicationInfo( - ctx.packageName, PackageManager.GET_META_DATA - ).metaData ?: return null - val dsn = meta.getString("com.comapeo.core.sentry.dsn") ?: return null - return SentryConfig( - dsn = dsn, - environment = meta.getString("com.comapeo.core.sentry.environment"), - release = meta.getString("com.comapeo.core.sentry.release"), - sampleRate = meta.getString("com.comapeo.core.sentry.sampleRate")?.toDoubleOrNull(), - tracesSampleRate = meta.getString("com.comapeo.core.sentry.tracesSampleRate")?.toDoubleOrNull(), - rpcArgsBytes = meta.getString("com.comapeo.core.sentry.rpcArgsBytes")?.toIntOrNull(), - ) -} -``` - -The loaded `SentryConfig` is consumed in two places: - -1. **Native SDK init (Android FGS process).** `SentryAndroid.init(ctx) - { options -> options.dsn = config.dsn; ... }` in the FGS - `Application.onCreate`. Allows the FGS process to capture native - crashes, ANRs, and the §7.4 telemetry events with the same DSN. - On iOS the host app's `@sentry/react-native` already owns the - single-process SDK; we don't re-init. -2. **Backend init frame.** When `NodeJSService.sendInit(rootKey)` - builds the `init` frame, it embeds `SentryConfig` as a - `sentry` field (see §4.5). The backend `Sentry.init()`s - synchronously inside the existing `init` handler, before - `MapeoManager` is constructed and before - `ComapeoRpcServer.listen` registers `onRequestHook`. No JS - round-trip; the FGS-cold-start path is fully covered. - -This is the key change vs. the prior draft: **the backend boot -sequence does not depend on RN being alive to be observable**. - -### 4.3 JS adapter handoff - -JS-side listeners (§6) need a callable Sentry object — `startSpan`, -`captureException`, `getTraceData`. Because the host app already -runs `Sentry.init()` from `@sentry/react-native` (which reads the -same Info.plist / manifest values via its own auto-config), -`configureSentry` exists purely to hand that initialized client -to this module: - -```ts -// File: src/sentry.ts (new) -import type * as SentryReactNative from "@sentry/react-native"; - -export type SentryAdapter = Pick< - typeof SentryReactNative, - | "captureException" - | "captureMessage" - | "startSpan" - | "continueTrace" - | "getActiveSpan" - | "getTraceData" - | "addBreadcrumb" ->; - -export interface ComapeoSentryConfig { - /** - * The host app's already-initialized `@sentry/react-native` - * (or any object satisfying `SentryAdapter`). The module - * never calls `Sentry.init()`; the host app does, and the - * native SDK is initialized from manifest/plist values - * written by the config plugin. - */ - sentry: SentryAdapter; -} - -/** - * Hand off the host app's Sentry adapter so this module's JS - * listeners can call into it. Idempotent and one-shot. - * - * Must be called before the first `comapeo.*` RPC if you want - * client-side spans on those calls. State observers attach - * immediately on call. - * - * Note: this does NOT configure DSN/environment/release. Those - * are baked into native config at build time by the Expo plugin - * and read by both `@sentry/react-native` (in the main process) - * and the embedded backend (in the FGS or iOS app process). - */ -export function configureSentry(config: ComapeoSentryConfig): void; -``` - -Consumer usage in CoMapeo Mobile: - -```ts -import * as Sentry from "@sentry/react-native"; -import { configureSentry } from "@comapeo/core-react-native/sentry"; - -// Sentry SDK reads DSN from Info.plist / manifest; the plugin -// wrote those values at build time. -Sentry.init({ /* override options if needed */ }); - -configureSentry({ sentry: Sentry }); -``` - -Apps that don't want Sentry don't import the sub-export and don't -register the plugin. The main barrel -(`@comapeo/core-react-native`) keeps no Sentry imports; the only -typecheck-time pull-in for opt-in consumers is the -`SentryAdapter` type. - -### 4.4 Runtime opt-in toggle (forward reference) - -A persisted "capture application data" boolean lives in native -preferences. It gates the *additional* observability surface -described in §7.4 (per-RPC method spans, sync session spans, -counts) but never touches DSN/environment/release and never -unlocks PII fields. See §9 for full design. - -### 4.5 Control-socket payload (internal) - -For completeness — the `init` frame written by native to the -backend now carries an optional `sentry` field: - -```js -// Native → Node, on control.sock -{ - type: "init", - rootKey: "", - sentry: { - dsn: "https://…", - environment: "production", - release: "1.4.2", - sampleRate: 1.0, - tracesSampleRate: 0.1, - rpcArgsBytes: 0, - captureApplicationData: false // §9 toggle, snapshot at boot - } -} -``` - -The backend `init` handler (`backend/index.js`) calls -`initSentry(message.sentry)` before resolving `initPromise`. The -DSN is therefore short-lived in process memory only; not in argv, -not in env, not on disk past the manifest read. - ---- - -## 5. Backend instrumentation (`backend/`) - -Mirrors `comapeo-mobile/src/backend/src/app.js`, adapted to this -module's two-socket boot. - -### 5.1 Bundle strategy - -`@sentry/node` becomes a `dependencies` entry of `backend/package.json` -and gets rolled into the bundle. The built backend therefore -contains the SDK whether or not anyone uses it. - -Bundle-size cost: `@sentry/node` core is ~150–250 KB minified + -gzipped depending on integrations imported. Acceptable for the -APK/IPA but not zero. Mitigations: - -- Subpath-import only what we need - (`@sentry/node/init`, `@sentry/core`) rather than the full - default bundle. We do **not** want HTTP / Express / undici - auto-instrumentation in this Node — the only network surface - is the local fastify on 127.0.0.1. -- Exclude OTLP exporters; the only transport we need is the - Sentry HTTPS transport that ships in `@sentry/node`. -- Confirm rollup can tree-shake; if not, the bundle plugin - config in `backend/rollup.config.ts` may need an explicit - `external: []` adjustment. - -A future optimisation if size matters more than build simplicity -(§9.2): produce a second backend bundle with Sentry stripped, and -have the native module pick which assets dir to copy into -`nodejs-project/` based on host-app config. Not in v1. - -### 5.2 `Sentry.init()` location - -In `backend/index.js`, before any other side-effecting import that -might throw and before `controlIpcServer.listen()`: - -```js -// backend/index.js (sketch) -import * as Sentry from "@sentry/node"; - -let sentryActive = false; - -function initSentry(config) { - Sentry.init({ - dsn: config.dsn, - environment: config.environment, - release: config.release, - sampleRate: config.sampleRate ?? 1.0, - tracesSampleRate: config.tracesSampleRate ?? 0, - integrations: [ - // Keep this list explicit — auto-discovery pulls in - // http/express/etc. that we don't want. - Sentry.consoleLoggingIntegration(), - ], - // tag every event so we can split JS vs native vs backend - // in Sentry's UI. - initialScope: { tags: { layer: "backend" } }, - }); - sentryActive = true; -} -``` - -The `init` handler in `controlIpcServer` calls `initSentry` if the -frame includes a `sentry` field, before resolving `initPromise`: - -```js -init: (message) => { - // … existing rootKey validation … - if (message.sentry) { - try { initSentry(message.sentry); } - catch (e) { console.error("Sentry init failed", e); } - } - resolveInit(rootKey); -} -``` - -### 5.3 Error capture wiring - -Three failure surfaces in `backend/index.js` to retrofit: - -1. **`handleFatal(phase, error)`** — already the single funnel for - uncaught exceptions, unhandled rejections, and boot-phase - throws (`listen-control`/`init`/`construct`/`runtime`). Add: - - ```js - if (sentryActive) { - Sentry.captureException(err, { - tags: { phase, layer: "backend" }, - }); - // Ensure the event is flushed before process.exit(1). - await Sentry.flush(100).catch(() => {}); - } - ``` - - The 100 ms flush window aligns with the existing - `broadcastError` flush — both run inside the same - pre-exit window, in parallel. - -2. **`error-native` handler** — frames forwarded from Android - FGS-local failures (rootkey, watchdog) reach `handleFatal` - with the FGS-supplied phase, so they get captured by #1 - automatically. We add a `tags: { source: "native" }` so - Sentry can filter cross-process forwarding. - -3. **Per-RPC errors** — handled in §5.4. - -### 5.4 RPC tracing — server side - -Replicates the `onRequestHook` from -`comapeo-mobile/src/backend/src/app.js`, called from -`backend/lib/comapeo-rpc.js`: - -```js -// backend/lib/comapeo-rpc.js (sketch) -import * as Sentry from "@sentry/node"; - -export class ComapeoRpcServer extends ServerHelper { - constructor(manager, { sentry } = {}) { - super((socket) => { - const messagePort = new SocketMessagePort(socket); - messagePort.start(); - const server = createMapeoServer(manager, messagePort, { - onRequestHook: sentry ? makeSentryRequestHook() : undefined, - }); - messagePort.on("close", () => server.close()); - }); - } -} - -function makeSentryRequestHook() { - return (request, next) => { - const sentryTrace = request.metadata?.["sentry-trace"]; - const baggage = request.metadata?.baggage; - return Sentry.continueTrace({ sentryTrace, baggage }, () => - Sentry.startSpan( - { - op: "rpc", - name: request.method.join("."), - forceTransaction: true, - attributes: { - "rpc.method": request.method.join("."), - // args intentionally omitted unless rpcArgsBytes>0 - }, - }, - async (span) => { - try { - await next(request); - span.setStatus({ code: 1, message: "ok" }); - } catch (error) { - span.setStatus({ code: 2, message: "internal_error" }); - Sentry.captureException(error, { - tags: { layer: "backend", op: "rpc" }, - }); - throw error; - } - }, - ), - ); - }; -} -``` - -Differences from the comapeo-mobile reference: - -- The hook is only registered when Sentry is active; absent - config, `createMapeoServer` is called without - `onRequestHook` and there is zero overhead. -- We rethrow after `captureException` so the IPC error path - still returns a rejection to the JS caller. The reference - swallows it inside `startSpan`'s callback, which silently - resolves the RPC promise — that loses error visibility - on the JS side. -- `request.args` is not serialized by default. In CoMapeo data - the args can be project-scoped content (observation fields, - attachments). PII risk is high, so opt-in only via - `rpcArgsBytes`. - -### 5.5 OpenTelemetry forwarding (PR #1051) - -When `comapeo-core` PR #1051 merges, `@comapeo/core` will emit -OpenTelemetry spans through the global `@opentelemetry/api` -provider. `@sentry/node` v8+ is built on OpenTelemetry: spans -emitted via `@opentelemetry/api` are picked up automatically by -the Sentry span processor. - -Concretely, after `Sentry.init()`, no further wiring is needed — -`@comapeo/core`'s spans become children of the active Sentry -transaction (the RPC span from §5.4) and ship to the configured -DSN. - -If PR #1051 lands before this integration, we should verify the -parent span linkage in a manual smoke test (see §10). - ---- - -## 6. JS / RN module instrumentation (`src/`) - -### 6.1 New files - -- `src/sentry.ts` — public sub-export. Exposes - `configureSentry()`, types, and the wrapped client. -- `src/sentry-internal.ts` — module-private state holding the - active adapter (or `null`), keyed reads for the RPC wrapper. - -The main barrel (`src/index.ts`) is unchanged so consumers who -don't import the sub-export get no Sentry types or runtime code -linked in. - -### 6.2 RPC client tracing — request side - -The existing `comapeo` client is created once at module load: - -```ts -// src/ComapeoCoreModule.ts:71-72 -const messagePort = new CoreMessagePort() as unknown as MessagePort; -export const comapeo: MapeoClientApi = createMapeoClient(messagePort); -``` - -To attach `sentry-trace` + `baggage` headers as `request.metadata` -on outgoing RPC frames, we have two options: - -**Option A — IPC-level metadata factory** (preferred) - -`@comapeo/ipc/client.js` already supports `request.metadata` on -the wire (the server reads it in `onRequestHook`). If -`createMapeoClient` accepts (or can be extended to accept) a -`getMetadata(method)` option, we register one that returns the -current trace headers from the active Sentry adapter: - -```ts -// src/ComapeoCoreModule.ts (changed) -import { activeAdapter } from "./sentry-internal"; - -export const comapeo: MapeoClientApi = createMapeoClient(messagePort, { - getMetadata: () => { - const a = activeAdapter(); - if (!a) return undefined; - // Sentry v8 helper that returns sentry-trace + baggage. - const { "sentry-trace": st, baggage } = a.getTraceData(); - return st ? { "sentry-trace": st, baggage } : undefined; - }, -}); -``` - -Verify whether the installed `@comapeo/ipc` (currently `^8.0.0`) -exposes such a hook. If it doesn't, file an upstream issue and -fall back to Option B for the interim. - -**Option B — Method proxy wrapper** - -`configureSentry` returns a Proxy-wrapped clone of `comapeo` -where each method call: -1. Starts a `Sentry.startSpan({ op: "rpc.client", name: ... })`. -2. Reads `getTraceData()` for headers. -3. Calls the underlying `comapeo` method with a wrapped first - argument that smuggles the headers — but this only works if - the IPC supports per-call metadata, which collapses Option B - into Option A. - -If neither path is possible without an upstream change to -`@comapeo/ipc`, we accept JS-side spans without distributed -tracing for v1 (the backend still produces its own spans, just -unlinked) and pursue the IPC change as a follow-up. - -### 6.3 State observer capture - -`state` already surfaces every error condition the JS layer -sees. `configureSentry()` registers two listeners: - -```ts -state.addListener("stateChange", (s, info) => { - if (s !== "ERROR" || !info) return; - const e = new Error(info.errorMessage); - e.name = `ComapeoError:${info.errorPhase}`; - adapter.captureException(e, { - tags: { - layer: "rn", - "comapeo.phase": info.errorPhase, - "comapeo.state": s, - }, - }); -}); - -state.addListener("messageerror", (err) => { - adapter.captureException(err, { - tags: { layer: "rn", source: "control-socket" }, - level: "warning", - }); -}); -``` - -Phase tags align with the values produced in -`src/ComapeoCore.types.ts` and the native sources -(`rootkey`, `node-runtime-unexpected`, `shutdown-timeout`, -`starting-timeout`, `ipc`, `listen-control`, `init`, -`construct`, `runtime`). They become Sentry filterable tags so -the team can dashboard "rootkey load failure rate" or "FGS -watchdog timeout rate" without parsing message strings. - -### 6.4 Public client error capture - -The IPC client surfaces RPC errors as rejected promises. Most -captures happen on the backend side (§5.4) and reach Sentry from -there with full context. The JS side adds a thin -`captureException` for client-perceived errors (e.g. RPC timeouts, -disconnect mid-call) that the backend never observed: - -```ts -// inside the wrapper or proxy from §6.2 -async (...args) => { - return Sentry.startSpan({ op: "rpc.client", name: method }, async () => { - try { - return await underlying[method](...args); - } catch (e) { - // Only capture if it didn't originate from a backend - // event we already see in §5.4 — the backend tags its - // captures with `layer: "backend"`. Backend RPC failures - // arrive here as plain errors, but Sentry de-dupes if - // the same exception is captured twice with different - // contexts. Acceptable. - Sentry.captureException(e, { - tags: { layer: "rn", op: "rpc.client", "rpc.method": method }, - }); - throw e; - } - }); -} -``` - ---- - -## 7. Native instrumentation (`ios/`, `android/`) - -The host app's `@sentry/react-native` already configures the -underlying `sentry-cocoa` and `sentry-android` SDKs for the main -process. What's left for this module: - -### 7.1 Loading config and forwarding to the backend - -Native reads `SentryConfig` from the manifest / Info.plist -(§4.2) at process start. There is no JS bridge call required; -config is in place before RN can boot. - -- **iOS**: `AppLifecycleDelegate.application(_:didFinishLaunchingWithOptions:)` - reads `Bundle.main.infoDictionary` and stores `sentryConfig` on - `NodeJSService` before `runNode()`. -- **Android (FGS)**: `ComapeoCoreService.onCreate` reads - `packageManager.getApplicationInfo(...).metaData` and stores - `sentryConfig` on `NodeJSService` before `start()`. -- **Android (main process)**: reads the same metaData when the - `ComapeoCoreModule` first instantiates, used only for the - control-IPC observer to add §7.4 breadcrumbs/events from the - main process. The main-process Sentry SDK is already - initialized by `@sentry/react-native` reading the same values - via its own pathway — we don't re-init. - -The stored config is embedded in the `init` frame -(§4.5) when `NodeJSService.sendInit(rootKey)` runs. The -runtime opt-in toggle (§9) is read from native preferences at the -same moment and merged into the same payload. - -### 7.2 Android FGS process - -The FGS runs in the `:ComapeoCore` process — see -`ARCHITECTURE.md §2.2`. `Sentry.init()` in the host app's -`MainApplication` runs only in the main process; the FGS process -gets a fresh `Application` and needs its own init. - -Two options: - -1. **Host-app responsibility.** Document that the host app's - `MainApplication.onCreate` should detect the FGS process and - call `SentryAndroid.init(...)` with the same DSN there. - `@sentry/react-native` does not handle multi-process - automatically. -2. **Module convenience.** Add a helper - `ComapeoCoreInit.installSentryInFgs(application, options)` that - the host calls from its `MainApplication`. The helper detects - `getProcessName().endsWith(":ComapeoCore")` and conditionally - inits `SentryAndroid`. - -Option 2 keeps the cross-process detail inside the module that -introduced the second process. Recommended. - -### 7.3 Native error tagging — see §7.4.7 - -The cross-process error attribution detail moved into §7.4.7 -alongside the rest of the native telemetry data design. - -### 7.4 Native telemetry data design - -This is the heart of the native instrumentation. Sentry has a -small set of primitives, each suited to different kinds of data. -We design the captures around them rather than dumping logs: - -| Sentry primitive | Use for | Example | -|---|---|---| -| **Breadcrumb** | Lightweight ordered context — what led up to an event. Cheap, capped at ~100 by default, attached to the next event. | "state STARTING→STARTED at t+312ms", "ipc connected", "FGS notification posted" | -| **Transaction** (root span) | A timed unit of work with a clear start/end and a name. Indexed; dashboards can chart durations and counts. | `comapeo.boot` (start→started), `comapeo.shutdown` (stop→stopped) | -| **Span** (child) | A nested timed sub-step inside a transaction. | `boot.listen-control`, `boot.init`, `boot.construct`, `boot.ipc-connect` | -| **Event** (`captureMessage` / `captureException`) | A discrete error or notable occurrence; full stacktrace + context. | rootkey load failure, watchdog timeout fired, FGS killed by OS | -| **Tag** | Indexed key/value pair on events — used for dashboard filtering. | `phase:rootkey`, `proc:fgs`, `comapeo.state:ERROR`, `platform:android` | -| **Context** (custom) | Structured but non-indexed — appears on event detail pages. | `{"comapeo": {"abi": "arm64-v8a", "nodejs_mobile_version": "...", "ipc_socket_age_ms": 1234}}` | -| **User** (anonymized) | A stable but non-identifying user/session id. | host-app-supplied install ID; never the rootkey | - -The remainder of this section walks through what each layer of -the native architecture (state machine, boot phases, timeouts, -IPC, FGS lifecycle) maps onto. - -#### 7.4.1 State transitions → breadcrumbs - -Every `ComapeoState` transition (`STOPPED`/`STARTING`/`STARTED`/ -`STOPPING`/`ERROR`) is captured as a breadcrumb on both the -FGS-side and main-process Sentry scopes: - -```kotlin -// android/.../NodeJSService.kt (FGS), inside the state-derivation -// callsite that already runs deriveState(...) -Sentry.addBreadcrumb(Breadcrumb().apply { - category = "comapeo.state" - level = if (newState == STARTED || newState == STOPPED) - SentryLevel.INFO - else if (newState == ERROR) - SentryLevel.ERROR - else SentryLevel.INFO - message = "$oldState → $newState" - setData("from", oldState.name) - setData("to", newState.name) - setData("backendState", backendState.javaClass.simpleName) - setData("nodeRuntime", nodeRuntime.javaClass.simpleName) - setData("stopRequested", stopRequested) -}) -``` - -These never trigger an upload by themselves — they ride along -on the next event. When something does fire (an ERROR transition, -a captured exception), the dashboard shows the last ~30 seconds of -state history leading up to it. That's exactly the data needed -to debug "why did this end up in ERROR" questions. Always-on. - -#### 7.4.2 Boot as a transaction with phase spans - -Boot is the single most error-prone path in the system. We model -it as a Sentry transaction that spans from `start()` to either -`STARTED` or `ERROR`: - -``` -Transaction: comapeo.boot (op = "boot") -├─ Span: boot.listen-control -├─ Span: boot.ipc-connect (control) -├─ Span: boot.rootkey-load (FGS only) -├─ Span: boot.init (rootkey handshake) -├─ Span: boot.construct (MapeoManager + RPC bind) -└─ Span: boot.ipc-connect (comapeo) -``` - -Each phase corresponds to a stage already named in -`backend/index.js` (the catch tags `phase` on errors with these -exact strings). On the native side, each phase is bracketed by -the existing log calls — we just add `Sentry.startSpan` around -them. Phases that throw set the span status to `internal_error` -and capture the exception; phases that succeed set `ok`. - -The transaction is **always-on essential telemetry**: durations -at boot are first-class signal for performance regressions -(rootkey load took 2s instead of 50ms? new device security -hardware quirk). Native sample rate is independent of -`tracesSampleRate` — we sample boot at 100% even when -`tracesSampleRate=0.01` for RPC because boot fires once per -process and is high-value. Implemented via -`Sentry.startSpan({ ..., forceTransaction: true })` and a -dedicated boot-tag inspected by an event processor that lifts -its sample rate to 1.0. - -#### 7.4.3 Shutdown as a transaction - -Symmetric: `comapeo.shutdown` transaction from `stop()` to -final `STOPPED` (or `ERROR` if shutdown timed out). Spans -for `shutdown.broadcast-stopping`, `shutdown.close-rpc`, -`shutdown.node-join`. Surfaces the difference between graceful -shutdowns (under the 10 s budget) and watchdog-killed ones. - -#### 7.4.4 Timeouts → events (always) - -Every timeout enumerated in `ARCHITECTURE.md §5.7` becomes a -Sentry event when it fires, tagged with which timeout it was: - -| Timeout | Sentry shape | Tags | -|---|---|---| -| iOS `startupTimeout` (30s) | `captureMessage("comapeo: startup timeout fired")` `level=error` | `timeout:startup, platform:ios, layer:native` | -| iOS `stop(timeout:)` | `captureMessage("comapeo: stop timeout fired")` `level=warning` | `timeout:shutdown, platform:ios` | -| iOS `waitForFile` | `captureMessage("comapeo: waitForFile timeout")` `level=error` | `timeout:waitForFile, socket:` | -| iOS `connectWithRetry` exhausted | event with `attempts` context | `timeout:connectRetry` | -| Android `startupTimeoutMs` (30s) | `captureMessage(...)` `level=error` | `timeout:startup, platform:android, proc:fgs` | -| Android FGS `withTimeout` (10s) on stop | `captureMessage(...)` `level=error` | `timeout:fgsStop, proc:fgs` | -| Android `SEND_ERROR_NATIVE_TIMEOUT_MS` (2s) | breadcrumb + `level=warning` event | `timeout:errorNativeForward` | -| Android `waitForFile` (30s) | `captureMessage(...)` `level=error` | `timeout:waitForFile` | - -Timeouts are the most actionable signal for "something is -silently broken" — they always fire something we never want -to pre-emptively recover from. Always-on essential telemetry. - -#### 7.4.5 IPC connection lifecycle → breadcrumbs + events - -`NodeJSIPC.State` transitions -(`Connecting`/`Connected`/`Disconnecting`/`Disconnected`/`Error`) -become breadcrumbs at `category: "comapeo.ipc"`. Disconnects from -a `Connected` state in non-stopping conditions also fire an -event tagged `ipc.unexpected_disconnect:true` with the -pre-disconnect JS state — that's the path that derives `ERROR` -phase `node-runtime-unexpected` (`ARCHITECTURE.md §5.4`), -useful to surface separately from controlled disconnects. - -#### 7.4.6 FGS lifecycle → breadcrumbs - -Android-only: the `ComapeoCoreService` lifecycle hooks -(`onCreate`, `onStartCommand`, `onTaskRemoved`, `onDestroy`) and -notification post/cancel become breadcrumbs at -`category: "comapeo.fgs"`. FGS-killed-by-OS scenarios (the FGS -process dies without `onDestroy` running) appear in -`sentry-android`'s session-replay-style detection if it's -enabled — we don't add custom code for that. - -#### 7.4.7 Native error tagging (was §7.3) - -When `NodeJSService` enters ERROR locally (rootkey load, -watchdog), it already populates `_lastError` and emits -`stateChange`. The JS-visible capture happens in §6.3, but on -Android FGS that capture lands in the *main* process — the -FGS's own context (logcat tail, foreground state, notification -ID) is in the *FGS* process's Sentry scope. - -If the FGS-side Sentry SDK is initialised (§4.2), we also call -`Sentry.captureException` from the FGS error handler, tagged -`proc:fgs phase:`, **before** forwarding the -`error-native` frame to Node. The duplicate event (FGS-side + -backend-side via `error-native` re-broadcast + main-process -JS-side via `stateChange`) is deduplicated by Sentry's -fingerprinting; the three captures together carry the FGS -context, the backend stack, and the main-process state-machine -trail. - -iOS doesn't need this — the FGS doesn't exist there, everything -runs in the host app process and the host app's -`@sentry/react-native` already covers it. - -#### 7.4.8 Categorization: essential vs opt-in - -| Capture | Tier | Rationale | -|---|---|---| -| State transition breadcrumbs | **Essential** | Cheap, ride on existing events. Required to debug ERROR paths. | -| Boot transaction + phase spans | **Essential** | Once-per-process, high-value perf signal. Forced 100% sample. | -| Shutdown transaction + phase spans | **Essential** | Same reasoning — once-per-process. | -| Timeout events | **Essential** | Always actionable; never silent recovery. | -| ERROR `captureException` (FGS, backend, main) | **Essential** | Already fires; this plan just structures it. | -| IPC connection breadcrumbs | **Essential** | Cheap; required to attribute disconnect-derived ERROR. | -| Unexpected-disconnect event | **Essential** | High-signal failure mode. | -| FGS lifecycle breadcrumbs | **Essential** | Cheap; required to debug FGS-killed-by-OS scenarios. | -| Per-RPC method spans (sampled) | **Opt-in** (capture application data on) | High volume; usable for performance dashboards but only when the user consented. | -| Sync session transaction (start → ready → finish, with peer count) | **Opt-in** | Reveals usage cadence. Counts only — no peer identities. | -| Background/foreground transitions | **Opt-in** | Reveals usage patterns. | -| Backend memory/heap snapshots (periodic) | **Opt-in** | Cost is non-trivial; only needed for memory-leak hunts. | -| Storage size of `privateStorageDir` (periodic) | **Opt-in** | Dataset-size signal. | - -#### 7.4.9 Hard never-capture list - -Independent of any toggle, these are off by construction — -not behind a config option, not behind `rpcArgsBytes>0`, not -ever: - -- The 16-byte rootkey, in any encoding. -- Identity public/secret keypairs derived from the rootkey. -- Observation contents (text, attachments, attachment paths). -- Precise location (lat/lng). If we ever want geographic - distribution data, it goes through quantization to - ~country/region resolution at the host-app layer, never - here. -- User-entered text from any settings UI. -- Project IDs in raw form. If included as a tag, must be - hashed (SHA-256, truncated to 16 chars) at capture site. -- Peer device identities or discovered peer counts above - bucketed thresholds (e.g. record `peers_bucket: 1-3 / 4-10 / 10+`, - not raw counts). -- File paths under `Application Support` or - `getFilesDir()` that include the rootkey or project IDs. - -A `before_send` event processor enforces the list -defensively: it walks the event tree for known sensitive -substrings (`rootKey`, base64-shaped 22-char strings, `lat=`, -`lng=`, `latitude:`, `longitude:`) and either redacts or -drops the event. This is belt-and-suspenders — the fix is -always at the capture site, but the processor catches -mistakes before they ship. - -### 7.5 Hard-crash reporting - -Crashes that bypass JS (SIGSEGV in a native addon, OOM kill, -`process.abort()`) are documented in `ARCHITECTURE.md §6` as -"belong in a separate channel". `sentry-cocoa` and -`sentry-android` already handle native crashes for the host app -process; on Android the FGS process needs its own init (§7.2) -to capture FGS-process crashes. - -We do not bundle `sentry-native` into the embedded `nodejs-mobile` -runtime. A V8 abort or libnode crash will not produce a Sentry -event from inside Node — but it will produce an Android-process -crash (since the FGS process dies) which `sentry-android` will -capture with a stacktrace from the JNI side. - ---- - -## 8. PII, sampling, and privacy - -CoMapeo data is sensitive (observation locations, attachments, -device identities). Defaults must avoid leaking it into Sentry: - -- **`request.args` is never serialized** unless - `rpcArgsBytes > 0` is explicitly set. Method names and - metadata only. -- **No project IDs in span names**; only RPC method paths - (`project.observation.create`, etc.). If we later want - per-project breakdowns, hash the project ID before adding - it as a tag. -- **No rootkey, no public/secret keypair, no observation - contents** in event payloads. The `error-native` frame - carries phase + message; the backend `Sentry.captureException` - call does too. -- **Stacktraces** are fine — they may include filenames from - inside `@comapeo/core` and the bundled backend. No user data - unless an `Error.message` was constructed with one (audit - these on integration). -- **`tracesSampleRate`** defaults to `0` if unspecified. The - host app must opt into RPC tracing volume explicitly. -- **`sendDefaultPii`** (Sentry option) is left to the host - app's `Sentry.init()` and the backend init we forward; we - don't override it. - -A pre-merge checklist (§10) includes a `before_send` hook that -greps every outbound event for known sensitive substrings -(`rootKey`, `dsn`, base64-shaped 22-char strings) as a -defense-in-depth check during integration smoke tests. - ---- - -## 9. Runtime "capture application data" toggle - -A persisted boolean preference, off by default, that the host -app's settings UI exposes to the end user. When on, the -**opt-in** captures from §7.4.8 are emitted; when off (the -default), only the essential captures are. Crucially, this -never unlocks anything in the §7.4.9 never-capture list — the -two layers are independent. - -### 9.1 Persistence - -A native preference, written and read entirely on the native -side so it survives app uninstall-resistant in the same way -existing user prefs do (and is not a special concern at the -backup/restore layer): - -- **Android**: stored in - `EncryptedSharedPreferences("comapeo-core-prefs", ...)` — - the same `androidx.security.crypto` mechanism used elsewhere - in the module. Key: `sentry.captureApplicationData`. Read by - both the main process and the FGS process. -- **iOS**: stored in `UserDefaults.standard` keyed - `com.comapeo.core.sentry.captureApplicationData`. Read at - app delegate init. - -### 9.2 JS API - -The toggle is exposed alongside `configureSentry`: - -```ts -// File: src/sentry.ts (additions) -/** - * Read the persisted opt-in flag. Resolves with the - * current native-side value. Reads are sync-fast on both - * platforms but the API is async to match the bridge. - */ -export function getCaptureApplicationData(): Promise; - -/** - * Write the persisted opt-in flag. Returns when the write has - * been durably committed on the native side. - * - * IMPORTANT: the new value does NOT take effect until the next - * app launch. The current process keeps emitting whatever it - * was emitting at boot. This is documented in the host app's - * settings UI so the user knows to restart for the change to - * apply. - */ -export function setCaptureApplicationData(enabled: boolean): Promise; -``` - -### 9.3 Why restart-to-activate - -Two reasons: - -1. **Snapshot-at-boot semantics.** The flag's value is read - once, at process start, and embedded in the `init` frame - to the backend (`captureApplicationData: bool`). The - backend wires its `onRequestHook`, OTel sampler, and - custom span emitters based on that snapshot. Hot-toggling - would mean re-registering hooks on a live RPC server, - which adds a class of bugs (in-flight requests with one - instrumentation, new requests with another) for marginal - value. -2. **Predictable user expectation.** The user toggling - "capture more data for debugging" should reasonably - expect a clear before/after, not a partial transition - in the middle of an active sync session. - -A minor cost: if the user has an active issue right now, they -need to flip the toggle and restart the app to start -collecting. The host-app UI says exactly that. - -### 9.4 What the toggle gates - -When `captureApplicationData == true`, the following turn on -in addition to the essential set: - -- **Per-RPC client + server spans.** `tracesSampleRate` - effectively goes from 0 → its configured value (default - 0.1). Method names only; never args. Span attributes - include `rpc.method`, `rpc.status`, `rpc.duration_ms`. -- **Sync session lifecycle transaction.** A - `comapeo.sync.session` transaction from `connectPeers` - (or first peer-connected event) through to - `syncFinished`/`disconnect`. Spans inside for - `discover`, `handshake`, `replicate`. Counts only: - number of peers (bucketed), bytes transferred (bucketed), - duration. **No peer identities, no project IDs in raw - form.** -- **Background/foreground transitions** — host-app `pause` - and `resume` events become `comapeo.app.background` / - `comapeo.app.foreground` breadcrumbs that ride on - subsequent events, helping correlate timing - ("error fired 3s after app backgrounded"). -- **Backend memory checkpoint.** Once at `STARTED` and - every 60s thereafter, a custom context entry on the - next event with `process.memoryUsage()` snapshot - (rss, heapTotal, heapUsed). No event capture by - itself — context only. -- **`privateStorageDir` size sample.** Once at `STARTED`, - the on-disk size of dbFolder + indexFolder + customMaps - as a numeric `du`-style integer. Bucketed (`<10MB`, - `10–100MB`, `100MB–1GB`, `>1GB`) before sending to - avoid leaking the exact size of a sensitive dataset. - -### 9.5 Plumbing path - -``` -[user toggles in app settings] - │ - ▼ -setCaptureApplicationData(true) ─── JS ─── - │ - ▼ -ComapeoCoreModule.setCaptureApplicationData ─── Native bridge ─── - │ - ▼ -EncryptedSharedPreferences write (Android) ─── Persisted ─── -UserDefaults.set (iOS) - │ - ▼ -[user is told: restart required] - -============= NEXT LAUNCH ============= - -NodeJSService starts ─── Native ─── - │ - ▼ -read EncryptedSharedPreferences / UserDefaults - │ - ▼ -sentryConfig.captureApplicationData = true - │ - ▼ -embed in init frame to backend ─── Control socket ─── - │ - ▼ -backend initSentry({captureApplicationData}) ─── Node ─── - │ - ▼ -- onRequestHook registered (per-RPC spans) -- sync-session emitter registered -- memory-snapshot timer started -- tracesSampleRate raised to configured value -``` - -### 9.6 What the toggle never unlocks - -The §7.4.9 never-capture list applies regardless. Specifically: - -- The toggle does not raise `rpcArgsBytes` from 0; raw RPC - args remain off. (`rpcArgsBytes` is a separate **build-time** - config-plugin option for developer debug builds.) -- The toggle does not start capturing observation contents. -- The toggle does not start capturing precise location. -- The toggle does not start capturing peer identities. - -If a future requirement wants any of those, it lands as a -*separate*, more-restrictive opt-in (and likely never ships -to production at all). - -### 9.7 Default and migration - -Default value when the preference has never been written: -`false`. We never auto-enable. A user upgrading the app to a -version that introduces this toggle starts at `false` and only -enters extended capture when they explicitly flip the switch. - ---- - -## 10. Phasing - -### 10.1 Phase 1 — JS-side error capture (smallest delivery) - -- `configureSentry({ sentry })` adapter handoff (§4.3). -- `state` listeners capture ERROR transitions and - `messageerror` events via `@sentry/react-native` (§6.3). -- Ship as `@comapeo/core-react-native/sentry` sub-export. -- Host app (CoMapeo Mobile) calls `Sentry.init` itself. - -Value: immediate visibility into rootkey failures, watchdog -timeouts, IPC errors, and `messageerror` parse failures — -provided RN is alive when they fire. (The FGS-cold-start gap -is closed in Phase 2.) - -Cost: ~50 LOC in `src/sentry.ts`, no native or backend -changes, zero risk to other consumers. - -### 10.2 Phase 2 — Expo config plugin + native config consumption - -- New `app.plugin.js` at module root (§4.1). -- iOS reads Info.plist into `SentryConfig` at app delegate - init; Android reads manifest meta-data at FGS `onCreate`. -- Native error tagging (§7.4.7) and FGS-side - `SentryAndroid.init` from manifest values. -- State-transition breadcrumbs and boot transaction - (§7.4.1, §7.4.2) wired into the existing - `NodeJSService` state-derivation callsites. -- Timeout events (§7.4.4) on the existing watchdog firing - paths. - -Value: native-side error capture is live for production users -without depending on RN being alive. FGS cold-start path is -fully observable. Boot durations dashboarded. - -Cost: ~150 LOC native (Kotlin + Swift), ~50 LOC plugin, no -backend changes yet. - -### 10.3 Phase 3 — backend error capture + RPC tracing - -- Add `@sentry/node` to `backend/package.json`, bundle it. -- Extend `init` frame with optional `sentry` field (§4.5). -- `handleFatal` and `onRequestHook` wired (§5.3, §5.4). -- Client-side `getMetadata` (§6.2) for distributed tracing - (or accept JS-side spans without parent linkage if - `@comapeo/ipc` doesn't yet support it — track upstream). - -Value: RPC method-level errors and durations in Sentry; -backend boot failures with proper stacktraces; baseline -distributed tracing. - -Cost: ~200 LOC across backend, JS, and native; ~150–250 KB -bundle delta on every consumer (mitigations in §5.1). - -### 10.4 Phase 4 — `@comapeo/core` OpenTelemetry forwarding - -- Bump `@comapeo/core` once PR #1051 lands. -- Verify Sentry's OTel integration picks up the spans - with the RPC transaction as parent. -- Document any required tracing-config overrides. - -Value: deep traces inside core operations (sync, indexing, -hypercore) — the data Sentry's performance tab is designed -to surface. - -### 10.5 Phase 5 — capture-application-data toggle - -- Native preference store (Android `EncryptedSharedPreferences`, - iOS `UserDefaults`) with `getCaptureApplicationData` / - `setCaptureApplicationData` JS API (§9.2). -- Read on boot, embed in `init` frame, gates the §7.4.8 opt-in - captures (per-RPC method spans, sync session transaction, - background/foreground breadcrumbs, memory checkpoints, - storage size sample). -- `before_send` privacy processor (§7.4.9 enforcement). - -Value: opt-in detailed observability for users who consent, -useful for performance investigations and usage-pattern -debugging without exposing PII. - -Cost: ~150 LOC native + JS + backend. - -### 10.6 Phase 6 — refinements - -- Tune sample rates from production data. -- Optional: dual backend bundles for Sentry-free consumers - if bundle size becomes a concern. - ---- - -## 11. Test plan - -### 11.1 Unit / integration - -- `src/sentry.ts` accepts a fake adapter; assert - `captureException` is called for synthetic ERROR - `stateChange` events with the correct phase tag. -- `src/sentry.ts` is a no-op if `configureSentry` was never - called: the existing `comapeo` client should produce - identical wire frames (no `metadata` injected). -- Backend: build the bundle without a `sentry` field in - `init` and confirm `Sentry.init` is never called. - Build with the field and confirm `onRequestHook` is - registered (assert via metadata propagation). -- Config plugin: snapshot test that running the plugin with - a `sentry` argument writes the expected manifest - meta-data and Info.plist keys. Run without argument → - no entries written. -- Native config store: synthetic manifest / plist with - partial keys decode into `SentryConfig` with `null` for - missing optional fields; total absence returns `null`. -- Native breadcrumb emission: drive `NodeJSService` through a - scripted state-machine sequence and assert the breadcrumbs - posted to a fake Sentry SDK match the expected shape and - level mapping. -- Toggle persistence: write `setCaptureApplicationData(true)`, - read it back, kill the process, read it back again — value - survives. Re-launch and confirm the flag flows into the - init frame. -- `before_send` privacy processor: feed it events containing - base64-shaped strings, latitude/longitude markers, and raw - project IDs; assert each is redacted or dropped. - -### 11.2 Manual smoke - -- Run the example app with a temporary DSN (a test Sentry - project) configured via the plugin. Trigger: - - A deliberate JS-side throw inside a `comapeo.*` callback - → JS-layer event in Sentry. - - A backend throw via a debug RPC method → backend-layer - event with parent transaction. - - An Android FGS rootkey-store corruption (delete the - keystore alias) → ERROR event with `phase:rootkey` - from both FGS-process and main-process scopes, with - state-transition breadcrumbs in the trail. - - A node abort (`process.abort()` via a debug RPC) → - `sentry-android` native crash event. - - Force the FGS startup watchdog to fire (e.g. by - blocking `initPromise` in a test build) → timeout - event with `timeout:startup` tag. - - **FGS cold-start path**: from a freshly-killed app - state, trigger an FGS-only launch (background sync - intent) without bringing RN up. Verify boot - transaction lands in Sentry from the FGS process - alone. -- Toggle "capture application data" on, restart, and run - a scripted sync session. Confirm `comapeo.sync.session` - transaction appears with bucketed peer count and no - raw peer identities. Toggle off, restart, and confirm - the transaction stops appearing. -- Confirm no PII in events: open each event, scan for - base64-shaped 22-char strings, file paths under - `Application Support`, project secrets. -- Confirm distributed trace shows JS-client span → backend - RPC transaction → (with PR #1051) core operation spans. - -### 11.3 Regression - -- Run the existing `e2e/run-instrumented-tests.sh` and the - iOS `swift test` / `xcodebuild test` suite with - `configureSentry` *not* called → no behaviour change. -- Build size delta tracked: compare `android/src/main/assets/nodejs-project/` - bundle size before and after Phase 2. - ---- - -## 12. Open questions - -1. **Does `@comapeo/ipc@^8` support a client-side `getMetadata` - hook?** §6.2 hinges on this. If not, what's the upstream - path — patch + release, or temporary monkey-patch in this - module? -2. **Sentry SDK version**: pin to `@sentry/react-native@^6` and - `@sentry/node@^8`? The OpenTelemetry-first model only exists - in v8+ for Node and v6+ for React Native; older versions - force a different tracing API. -3. **Bundle size budget**: do we have a hard limit for the - embedded backend? §5.1 estimates suggest ~150–250 KB; if the - budget is tighter, plan for dual bundles in Phase 5. -4. **Release tagging**: how does `release` flow from the host - app (CoMapeo Mobile) into the backend? The natural source is - the host app's `package.json` version, but the backend bundle - is built inside this module — we'd need to surface the value - via the runtime config rather than baking it in at build time. -5. **Cross-process scope on Android**: Phase 3 assumes the FGS's - Sentry events can carry a `proc:fgs` tag. Confirm the host - app's `@sentry/react-native` config doesn't override our tag - in the main-process events. -6. **Release tagging via plugin**: §4.1 has the consumer pass - `release` as a literal in `app.json`. CoMapeo Mobile likely - wants this auto-derived from the host app's version. The - plugin can read `config.version` (the consumer's `expo.version`) - as a default; a `${VERSION}` placeholder is another option. - Decide which. -7. **Plugin output for empty config**: when the consumer - registers the plugin without a `sentry` argument (e.g. just - `["@comapeo/core-react-native"]`), the plugin should be a - no-op for Sentry. Confirm we don't accidentally write empty - `` entries that confuse the native loader. -8. **Toggle UI surface**: where does the host app expose the - `setCaptureApplicationData` switch? CoMapeo Mobile already - has a settings screen — coordinate the copy and restart - prompt. Out of scope for this module but called out for - integration. -9. **Boot transaction sample rate**: §7.4.2 forces 100% on boot - even when overall `tracesSampleRate` is low. Confirm this - doesn't blow Sentry quota for high-launch-volume users. - May need a 1-in-N sampler with a minimum floor. -10. **`EncryptedSharedPreferences` for the toggle**: it's - stronger than necessary for a non-sensitive boolean. Plain - `SharedPreferences` would be simpler and faster. Decision - pending unless we want the toggle's value masked from - on-device tooling, which seems unnecessary. - ---- - -## 13. Summary of file changes - -Concrete touch list, by phase, for code review. - -**Phase 1** - -- `src/sentry.ts` (new) — `configureSentry`, types, state listeners. -- `src/sentry-internal.ts` (new) — module-private adapter holder. -- `package.json` — add `@sentry/react-native` to `peerDependencies` - with `peerDependenciesMeta.optional: true`. -- `docs/sentry-integration-plan.md` (this file). - -**Phase 2 — Expo plugin + native config + breadcrumbs/spans** - -- `app.plugin.js` (new, module root) — `withAndroidManifest` to - inject `` and `withInfoPlist` to inject keys. -- `expo-module.config.json` — register the plugin if needed - (the file is already wired to expo-modules via this manifest). -- `ios/SentryConfigStore.swift` (new) — read Info.plist into - `SentryConfig`. -- `android/src/main/java/com/comapeo/core/SentryConfigStore.kt` - (new) — read manifest meta-data into `SentryConfig`. -- `ios/AppLifecycleDelegate.swift` — read config and stash on - `NodeJSService` before `runNode()`. -- `ios/NodeJSService.swift` — accept stored config, embed in - init frame. -- `android/src/main/java/com/comapeo/core/ComapeoCoreService.kt` - — read config in `onCreate`, init `SentryAndroid` for the - FGS process, pass to `NodeJSService`. -- `android/src/main/java/com/comapeo/core/NodeJSService.kt`, - `ios/NodeJSService.swift` — add `Sentry.addBreadcrumb` calls - on every state-derivation update; wrap boot phases in - `Sentry.startSpan`; emit timeout events. -- `android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt` - (main process) — same breadcrumb/event emission from the - control-IPC observer. - -**Phase 3 — backend instrumentation** - -- `backend/package.json` — `@sentry/node` dependency. -- `backend/index.js` — `initSentry`, hook `handleFatal`, extend - `init` handler validation. -- `backend/lib/comapeo-rpc.js` — accept `sentry` option, register - `onRequestHook`. -- `src/ComapeoCoreModule.ts` — pass `getMetadata` to - `createMapeoClient` (or wrapper fallback). - -**Phase 4 — OpenTelemetry forwarding** - -- `backend/package.json` — bump `@comapeo/core` once PR #1051 - ships. -- Smoke test verification, no code changes expected. - -**Phase 5 — capture-application-data toggle** - -- `android/src/main/java/com/comapeo/core/SentryPrefsStore.kt` - (new) — `EncryptedSharedPreferences` read/write of the - toggle, plus `getCaptureApplicationData` / - `setCaptureApplicationData` bridge. -- `ios/SentryPrefsStore.swift` (new) — `UserDefaults` - equivalent. -- `android/.../ComapeoCoreModule.kt`, `ios/ComapeoCoreModule.swift` - — Expo bridge `Function` entries for the two methods. -- `src/sentry.ts` — JS exports `getCaptureApplicationData`, - `setCaptureApplicationData`. -- `backend/lib/comapeo-rpc.js` — wire `tracesSampleRate` - conditionally on the toggle; register sync-session emitter - only when on. -- `backend/index.js` — accept `captureApplicationData` in - init payload; gate memory-checkpoint timer and storage - sampling. -- `backend/before-send.js` (new) — `before_send` privacy - processor (the §7.4.9 redaction belt-and-suspenders). - ---- From 944bdf7d4046c53afa60ac089cc6453e834a433e Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 13:29:25 +0100 Subject: [PATCH 12/33] fix(bench): create Resources PBXGroup before adding folder ref `pbxProject.addResourceFile` unconditionally calls `correctForResourcesPath`, which dereferences `pbxGroupByName('Resources')` without a null check. Default Expo prebuild output for an Expo SDK 55 app has no top-level `Resources` group, so the call crashed with `Cannot read properties of null (reading 'path')`. Fix: call `IOSConfig.XcodeUtils.ensureGroupRecursively(project, 'Resources')` before `addResourceFile`. The group itself has no `.path`, so the prefix-strip in `correctForResourcesPath` is a no-op, and `addToResourcesPbxGroup` correctly attaches the file ref under it. Verified end-to-end on iPhone 16 sim (iOS 26.2) and Pixel 7a API 29 emulator: bench app reaches STARTED state, runs the bench RPC, and renders 100-sample 64B p50/p95/p99 results. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/benchmark/plugins/with-comapeo-bench/index.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/benchmark/plugins/with-comapeo-bench/index.js b/apps/benchmark/plugins/with-comapeo-bench/index.js index 7e7d4ea..007a6b6 100644 --- a/apps/benchmark/plugins/with-comapeo-bench/index.js +++ b/apps/benchmark/plugins/with-comapeo-bench/index.js @@ -140,11 +140,13 @@ function withBenchIosResources(config) { if (project.hasFile && project.hasFile(relPath)) { return cfg; } - project.addResourceFile( - relPath, - { lastKnownFileType: 'folder' }, - undefined, - ); + // `pbxProject.addResourceFile` unconditionally calls + // `correctForResourcesPath` which crashes if the project has no + // 'Resources' PBXGroup yet. Default Expo prebuild output has no + // such group, so create it first — `addToResourcesPbxGroup` later + // attaches the file ref under it. + IOSConfig.XcodeUtils.ensureGroupRecursively(project, 'Resources'); + project.addResourceFile(relPath, { lastKnownFileType: 'folder' }); return cfg; }); return config; From 080295eea7953e43d58dabe7efbb4b2cfdf5d137 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 13:37:36 +0100 Subject: [PATCH 13/33] docs: drop benchmark plan; refresh App.tsx header The UDS / RPC bridge benchmark plan is now implemented as Phase 3 shipped, and the doc itself describes an earlier iteration (the `comapeoBench=true` toggle and `ENV['COMAPEO_BENCH']` Podfile mutation) that has since been refactored into the generic `comapeoBackendDir` override. Keeping it would only mislead. Refreshes App.tsx's header comment to reflect the current wiring and points at the new `apps/benchmark/README.md` (added in the next commit) instead of the deleted doc. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/benchmark/App.tsx | 9 +- docs/uds-rpc-bridge-benchmark-plan.md | 361 -------------------------- 2 files changed, 5 insertions(+), 365 deletions(-) delete mode 100644 docs/uds-rpc-bridge-benchmark-plan.md diff --git a/apps/benchmark/App.tsx b/apps/benchmark/App.tsx index 3a0ae6a..98be4f5 100644 --- a/apps/benchmark/App.tsx +++ b/apps/benchmark/App.tsx @@ -20,10 +20,11 @@ import { SafeAreaView } from "react-native-safe-area-context"; /** * Benchmark app entry. Drives the bench RPC bridge through the same * RN→native→Node UDS path as the production module, but talks to the - * stripped `backend/index.bench.js` (via the `comapeoBench=true` - * Gradle property on Android / `ENV['COMAPEO_BENCH']` iOS opt-in) — - * so timings isolate the framing / IPC / JSON-RPC bridge from - * `@comapeo/core` init noise. See `docs/uds-rpc-bridge-benchmark-plan.md`. + * stripped backend in `apps/benchmark/backend/` (selected via the + * module's `comapeoBackendDir` override that the + * `with-comapeo-bench` config plugin sets) — so timings isolate the + * framing / IPC / JSON-RPC bridge from `@comapeo/core` init noise. + * See `apps/benchmark/README.md` for architecture + run instructions. * * UI surface: * - boot status (state observer): waits for "STARTED" before enabling diff --git a/docs/uds-rpc-bridge-benchmark-plan.md b/docs/uds-rpc-bridge-benchmark-plan.md deleted file mode 100644 index bd42234..0000000 --- a/docs/uds-rpc-bridge-benchmark-plan.md +++ /dev/null @@ -1,361 +0,0 @@ -# UDS / RPC Bridge Benchmark Suite - -> **Note (2026-05-05):** this document captures the original v1 -> implementation plan, in which the bench bundle and rollup config -> lived inside the production module behind a `comapeoBench=true` -> Gradle toggle / `ENV['COMAPEO_BENCH']` Podfile mutation. The -> benchmark has since been refactored: the bench backend, rollup -> config, and Expo config plugin moved to `apps/benchmark/`, and the -> module exposes a generic `comapeoBackendDir` override (Gradle -> property → `BuildConfig.COMAPEO_BACKEND_DIR` on Android, -> `ComapeoBackendDir` Info.plist key on iOS) that the bench app's -> plugin sets. Goals (boot phases, RPC sweeps, sinks, Maestro flows) -> are unchanged; only the build wiring differs. For current behavior -> see `apps/benchmark/backend/`, `apps/benchmark/plugins/with-comapeo-bench/`, -> and the `comapeoBackendDir` blocks in `android/build.gradle` and -> `ios/AppLifecycleDelegate.swift`. - -## Context - -`@comapeo/core-react-native` connects React Native to a `nodejs-mobile` -runtime over a pair of UNIX-domain sockets (control + comapeo) with a -length-prefixed JSON RPC framing. We want to measure two things on real -devices via **BrowserStack App Automate**: - -1. **UDS connection initialisation** — the boot phases already named in - `backend/index.js` and modelled by the Sentry plan: `listen-control`, - `ipc-connect (control)`, `rootkey-load`, `init`, `construct`, - `ipc-connect (comapeo)`. -2. **RPC bridge performance** — round-trip latency for small messages and - throughput at varying payload sizes (e.g. 64B / 1KB / 64KB / 1MB), both - cold and steady-state. - -This is **phase 1**. Real `@comapeo/core` API benchmarks (project ops, sync, -sqlite) come later; here we want to isolate the parts of the bridge that -*we* own so device-specific regressions in framing / IPC / RPC plumbing -surface without `@comapeo/core` noise. The repo currently has **zero** -benchmark or timing instrumentation; the Sentry plan -(`docs/sentry-integration-plan.md`) is detailed but unimplemented. - -## Approaches considered - -- **Sentry-based (rejected as transport):** the Sentry plan defines exactly - the spans we want (`comapeo.boot` with phase children, `op:"rpc"` named by - `request.method`). But Sentry is sample-based, has per-span overhead, ships - via HTTPS to a remote service, and Phases 1–3 of the plan are unimplemented - prerequisites. Wrong transport for tight-loop microbenchmarks. **Reuse the - taxonomy, not the implementation.** -- **Native test runner only (rejected as primary):** `androidx.benchmark` / - XCTest `measure` blocks would run on BrowserStack natively and dump - reports without custom transport — but they don't exercise the - RN→native→Node JS path, only the native↔Node leg. Insufficient for RPC - round-trip timing as users feel it. -- **Standalone benchmark app + custom JS bundle (chosen):** isolates the - bits we own from `@comapeo/core` init noise; lets us drive the bridge - through the real RN→native→Node path; matches the existing aspirational - `e2e/.maestro/ipc-roundtrip.yaml` (which already references `send-button` / - `benchmark-result` IDs that `App.tsx` doesn't yet have). - -## Recommended design - -### Single benchmark app + stripped backend, with shared Sentry-shaped instrumentation - -- A new `apps/benchmark/` is a slim copy of `apps/example/`. UI exposes - payload-size selectors, a Send button (`testID="send-button"`), and a result - panel (`testID="benchmark-result"`). -- A new `backend/index.bench.js` is the bench-only nodejs-mobile entry. It - reuses the same `pre-listening` → `started` → `ready` state machine as - `backend/index.js` so the native module is unchanged, but **does not import - `@comapeo/core`** and registers only `echo` and `payload(sizeBytes)` RPC - methods. -- `scripts/build-backend.ts` learns a `--bench` mode that emits the bench - bundle into bench-only sibling paths (see "Consumer isolation" below) so it - cannot leak into a regular consumer app. -- A pluggable telemetry sink emits the Sentry plan's exact span shape - (`comapeo.boot` transaction with `boot.listen-control`, - `boot.ipc-connect (control)`, `boot.rootkey-load`, `boot.init`, - `boot.construct`, `boot.ipc-connect (comapeo)`, plus `op:"rpc"` spans named - `request.method.join(".")`). When Sentry plan Phase 3 lands, a single - `SentryAdapterSink` (~30 LOC) implementing the same `recordSpan` interface - drops in without changing call sites. -- Production `backend/index.js` is **not modified** in this phase. The shared - helpers live in `backend/lib/` and are consumed by `index.bench.js` only; - the Sentry plan can adopt them later. -- Maestro flows drive runs on BrowserStack App Automate. -- Results escape via **HTTP POST through the BrowserStack Local tunnel** to a - small Node receiver (`scripts/lib/bench-receiver.ts`) running on the - BrowserStack runner. - -## Consumer isolation (bench bundle ships only to the bench app) - -Hard requirement: a regular consumer (the example app, third-party apps -installing `@comapeo/core-react-native` from npm) MUST NOT receive any bench -artefacts in their APK or IPA. Three independent guards enforce this: - -1. **Path isolation in the working tree.** Production assets land at - `android/src/main/assets/nodejs-project/` and `ios/nodejs-project/` — - exactly the locations the AAR `sourceSets.main.assets` and the - podspec's `s.resources = ['nodejs-project']` already pick up - (`android/build.gradle:93-104`, `ios/ComapeoCore.podspec:48`). Bench - assets land at sibling paths the production module's gradle/podspec - does **not** reference by default: - - `android/src/bench/assets/nodejs-project/` *(new build variant - sourceSet, off by default)* - - `ios/nodejs-project-bench/` *(included only when the consumer's - Podfile sets `ENV['COMAPEO_BENCH'] = '1'` before pod install)* -2. **Default-off opt-in at consumer build time, driven by an Expo config - plugin (no checked-in `android/`/`ios/` folders).** - - The bench app declares its native wiring entirely in `app.json` / - `app.config.js` via a single config plugin - `./plugins/with-comapeo-bench`. `expo prebuild` regenerates the - `android/` and `ios/` directories on demand; nothing under those - paths is checked into git for `apps/benchmark/`. - - Android: the plugin uses `withGradleProperties` to write - `comapeoBench=true` into the consuming app's - `android/gradle.properties`. The module's own `android/build.gradle` - reads `rootProject.findProperty('comapeoBench')` and, when set, - replaces `assets.srcDirs` with `src/bench/assets/` (and zeroes out - `src/debug/assets/` so the production debug overlay doesn't shadow - the bench bundle in debug builds). Consumers that don't set the - property (`apps/example/`, third-party apps) keep the default - `src/main/assets/` and never see `src/bench/`. The earlier design - used a `bench` productFlavor + `missingDimensionStrategy`, but that - hit AGP / Gradle 9 strict variant resolution ambiguity for Expo - apps that don't declare matching flavors of their own (expo/expo - #18315 et al.); a project property dodges the variant-attribute - graph entirely. - - iOS: the plugin uses `withPodfile` (the canonical - `@expo/config-plugins` mod for Podfile string edits) to prepend - `ENV['COMAPEO_BENCH'] = '1'` to the regenerated `ios/Podfile` - above the autolinking block, so the env var is set before pod - install reads `ComapeoCore.podspec`. The module's - `ComapeoCore.podspec` reads that env var at `pod install` time - and, when set, stages a copy of `nodejs-project-bench/` to - `.bench-staging/nodejs-project/` and adds it to `s.resources` - **alongside** the production `nodejs-project/`. CocoaPods' - `[CP] Copy Pods Resources` rsyncs both into the app bundle in - declaration order, with the same destination basename - (`.app/nodejs-project/`); the bench overlay's `index.mjs` - replaces production's at the same path. If the bench bundle is - missing (forgot to run `--bench`), staging is skipped and only - the production bundle ships — the bench app boots production - instead of crashing on a missing resource. With no env var, the - podspec ships exactly today's `nodejs-project/`, byte-identical. - An earlier iteration tried to do the rename via an Xcode Run - Script build phase (added by the plugin via `withXcodeProject`) - but CocoaPods 1.x doesn't reliably position user script phases - after `[CP] Copy Pods Resources`, so the rename ran before the - bench files were on disk and silently no-op'd; the staging - approach sidesteps the ordering problem entirely. The Podfile - mutation guards with an `includes(sentinel)` check so re-runs of - `expo prebuild` are idempotent. Note: - `expo-build-properties.ios.extraPods` cannot express a subspec - opt-in (no `:subspecs` field) and only appends — it cannot - override the autolinked `pod 'ComapeoCore'` entry — which is why - the env-var-driven podspec is the right shape. -3. **Publish-time exclusion.** `package.json`'s `files` array does not - list `android/src/bench/` or `ios/nodejs-project-bench/`, so even if a - developer accidentally runs `--bench` before publishing, those paths - are physically excluded from the npm tarball. The bench app consumes - the working tree via Expo autolinking from `../..`, not from the - published package, so it still works locally. - -Net effect: a consumer running `npm install @comapeo/core-react-native` -followed by `expo prebuild` gets exactly today's APK/IPA. A consumer -that has the working tree locally but doesn't apply the -`with-comapeo-bench` plugin also gets exactly today's APK/IPA. Only the -bench app, whose `app.json` lists the plugin, links the bench bundle. - -### Native loader behaviour under the bench variant - -`nodejs-mobile` boots from a fixed `nodejs-project/` path inside the app -sandbox. To avoid changing the native loader, the bench variant -substitutes the bundle in place: - -- Android: when `comapeoBench=true`, the module's `android/build.gradle` - reassigns `sourceSets.main.assets.srcDirs` to `['src/bench/assets']` - (and empties `sourceSets.debug.assets.srcDirs` so the production - debug overlay doesn't shadow it). The bench bundle's relative path - inside its sourceSet is `nodejs-project/`, the same as production, - so the AAR ships exactly one `nodejs-project/` — bench's. Default - consumer (no property) keeps the production `src/main/assets`. -- iOS: the podspec stages `nodejs-project-bench/` to - `.bench-staging/nodejs-project/` at pod install time when - `ENV['COMAPEO_BENCH']` is set, and lists both `nodejs-project` and - `.bench-staging/nodejs-project` in `s.resources`. CocoaPods rsyncs - them in declaration order to `.app/nodejs-project/`; the bench - overlay's `index.mjs` replaces production's. The default build - (no env var) ships only the production `nodejs-project/`. - -Both variants leave the existing `NodeJSService.swift` and Android Node -launcher unchanged. - -## Standalone operation - -The bench app must be useful without any host-side infrastructure: a -developer should be able to `expo prebuild`, install the APK/IPA on any -device, tap Send, and read results on screen. Concretely: - -- The default sink is `JsonFileSink` writing to the app's Documents - directory (`/Documents/comapeo-bench/.ndjson`) plus an - on-screen render in the `benchmark-result` panel: per-phase boot - durations, per-payload-size RPC p50/p95/p99 over a fixed iteration - count. -- The `HttpSink` is **opt-in**, controlled by a UI toggle and an - optional URL field defaulting to `http://localhost:`. It posts - in addition to (not instead of) the on-device render, and any - network failure is logged and ignored — the on-device experience is - unchanged. -- An "Export results" button on the result panel reveals the file path - and (on iOS) opens the system share sheet so a user can pull NDJSON - off the device without `adb pull` / Xcode access. Useful for ad-hoc - device testing. -- A timestamped run id is shown on screen so screenshots from manual - runs can be cross-referenced if needed. - -## Release-variant correctness - -Real-app perf-feel is debug-misleading (interpreter JS, no R8/ProGuard, -unminified RN bundle). Both build types must work end-to-end: - -- Android: the `comapeoBench=true` Gradle property is orthogonal to - `debug` / `release` build types — it just selects which `assets` - srcDirs the AAR ships. R8 / ProGuard don't touch assets, so a - `release` build of the bench app bundles the same bench - `nodejs-project/` as `debug`. Verification: - `expo run:android --variant release` (or `./gradlew :app:assembleRelease` - in the prebuild output) and unzip-grep the APK. -- iOS: the podspec's `ENV['COMAPEO_BENCH']` check + staging copy run at - `pod install` time, before any per-configuration build, so Release - and Debug configurations both embed the bench bundle identically. - Verification: archive the bench app with the Release configuration - and inspect `.app/nodejs-project/`. - -## Critical files - -**Shared instrumentation (used now by `index.bench.js`, reused by Sentry Phase 3 later):** -- `backend/lib/telemetry-sink.js` *(new)* — `recordSpan({op, name, startNs, endNs, attrs})` interface plus `JsonFileSink` / `HttpSink` / `NoopSink` implementations. -- `backend/lib/boot-spans.js` *(new)* — helpers that wrap the four phase blocks (`listen-control`, `init`, `construct`, `ipc-connect (comapeo)`) plus `ipc-connect (control)` and `rootkey-load` with `recordSpan({ op:"boot", name:"boot." })`. Phase names mirror the existing `Object.assign(e, { phase })` tags in `backend/index.js` and the Sentry plan §7.4.2. - -**Bench backend entry:** -- `backend/index.bench.js` *(new)* — listens on the same control + comapeo socket paths, runs the same state machine, but skips `createComapeo` and registers `echo` / `payload(sizeBytes)`. Wires the boot-span helpers and exposes per-RPC timing via `comapeo-rpc`-equivalent server. -- `backend/lib/bench-rpc.js` *(new)* — minimal RPC server that accepts the bench methods and emits `op:"rpc"` spans on each request via the shared sink. -- `scripts/build-backend.ts` — add `--bench` mode. In bench mode rollup is invoked with `INPUT=index.bench.js` and `OUTPUT_DIR_*` pointing at `android/src/bench/assets/nodejs-project/` and `ios/nodejs-project-bench/` (NOT the production `src/main/` and `ios/nodejs-project/` paths). Default mode is unchanged. -- `backend/rollup.config.ts` — accept the entry override; exclude `@comapeo/core` and its drizzle migrations from the bench bundle. - -**Module-side wiring for the bench variant:** -- `android/build.gradle` — read `rootProject.findProperty('comapeoBench')`. When set, reassign `sourceSets.main.assets.srcDirs = ['src/bench/assets']` (replacing — not appending — the production `src/main/assets`) and zero out `sourceSets.debug.assets.srcDirs` so the debug overlay can't shadow bench in debug builds. Default consumer (`apps/example/`, third parties) leaves the property unset and keeps the production `src/main/assets`. The earlier `productFlavors` design hit AGP variant-attribute ambiguity for Expo apps without matching flavors of their own. -- `ios/ComapeoCore.podspec` — read `ENV['COMAPEO_BENCH']` at evaluation time. When set, stage `ios/nodejs-project-bench/` to `ios/.bench-staging/nodejs-project/` and add it to `s.resources` alongside the production `nodejs-project/`. CocoaPods' `[CP] Copy Pods Resources` rsyncs both into `.app/nodejs-project/` in declaration order, with the bench `index.mjs` overlaying. Default consumers leave the env var unset and ship the existing single `nodejs-project` resource, byte-identical. -- `package.json` — `files` array stays as-is; `android/src/bench/`, `ios/nodejs-project-bench/`, and `ios/.bench-staging/` are deliberately omitted (and explicitly negated via `!`-patterns) so they cannot leak via `npm publish`. - -**Bench app (no checked-in `android/`/`ios/`):** -- `apps/benchmark/` *(new)* — slim sibling of `apps/example/`, but only owns: `App.tsx`, `app.json`, `babel.config.js`, `metro.config.js`, `index.ts`, `package.json`, `tsconfig.json`, and `plugins/`. Native dirs are not checked in; `expo prebuild` generates them on demand using the config plugin below. Uses Expo autolinking back to `../..`, same pattern as `apps/example`. -- `apps/benchmark/app.json` *(new)* — declares the bench plugin **only**: `"plugins": ["./plugins/with-comapeo-bench"]`. Does **not** include `with-android-tests` or `with-ios-tests` from `apps/example/plugins/`. -- `apps/benchmark/App.tsx` *(new)* — UI with `testID="send-button"`, `testID="benchmark-result"`, payload-size selector, warmup/steady-state toggle, on-screen p50/p95/p99 render, "Export results" button, and an opt-in "POST to receiver" toggle + URL field (default `http://localhost:`, off by default). -- `apps/benchmark/plugins/with-comapeo-bench/` *(new)* — single config plugin. Uses canonical `@expo/config-plugins` mods only: - - `withGradleProperties` — writes `comapeoBench=true` into `android/gradle.properties`. Idempotent (lookup-then-update). The module's `android/build.gradle` reads this and swaps in the bench asset srcDirs. - - `withPodfile` — prepends `ENV['COMAPEO_BENCH'] = '1'` to `ios/Podfile` above the autolinking block, guarded by an `includes(sentinel)` check for idempotency. The podspec reads the env var at pod install time and stages the bench bundle. - - No `withXcodeProject` — an earlier iteration tried to add an Xcode Run Script build phase to rename `nodejs-project-bench/` → `nodejs-project/` in the app bundle, but CocoaPods 1.x doesn't reliably position user script phases after `[CP] Copy Pods Resources`, so the rename ran before the bench files were on disk and silently no-op'd. The pod-install-time staging in the podspec sidesteps the ordering problem entirely. - -**RN-side timing hook:** -- `src/ComapeoCoreModule.ts` (`CoreMessagePort`) — accept an optional JS-side `recordSpan` so RN-thread timestamps round-trip with each RPC. Same structural shape Sentry plan §6.2 will need later. - -**Maestro flows:** -- `e2e/.maestro/bench-rpc.yaml` *(new)* and per-payload-size flows (`bench-payload-64B.yaml`, `bench-payload-1KB.yaml`, `bench-payload-64KB.yaml`, `bench-payload-1MB.yaml`). - -**Receiver:** -- `scripts/lib/bench-receiver.ts` *(new)* — small Node HTTP receiver bound to localhost; collates incoming spans into per-device NDJSON and a CSV summary keyed by BrowserStack session id / device tag. - -## Results pipeline - -Two transports, ranked by who's running the bench: - -- **Default (always works, including offline):** `JsonFileSink` writes - NDJSON to the app's Documents directory and the app renders summary - stats on screen. An "Export results" button reveals the path / - triggers the share sheet on iOS. This is the only required transport - for a developer running the bench app standalone on any device. -- **Optional (orchestrated BrowserStack runs):** `HttpSink` POSTs every - span to a user-supplied URL (default `http://localhost:` reached - via BrowserStack Local tunnel). Toggle defaults to off. Connection - failures are silently logged so the on-device experience never - regresses when no receiver is listening. `bench-receiver.ts` writes - per-device NDJSON + CSV when in use. -- **No reliance on Sentry HTTPS upload.** - -## Phasing - -1. **Phase 1 (1–2 days):** shared sink (`telemetry-sink.js`, `boot-spans.js`) + - `backend/index.bench.js` skeleton with boot-phase spans wired. Verify - locally with `JsonFileSink` against a dev build. -2. **Phase 2 (2–3 days):** dual-bundle build wiring + consumer isolation - (`scripts/build-backend.ts --bench`, rollup config, `comapeoBench` - Gradle property toggle in the module's `android/build.gradle`, - env-var-driven resource staging in the module's - `ios/ComapeoCore.podspec`). Confirm production `nodejs-project/` is - byte-identical to before; bench bundle lands in - `android/src/bench/...` / `ios/nodejs-project-bench/` and is absent - from a default `apps/example/` build (release variant too). -3. **Phase 3 (3–5 days):** `apps/benchmark/` skeleton (no checked-in - `android/`/`ios/`) with `App.tsx` UI, RPC bridge wiring, per-payload-size - handlers, warmup/steady-state logic, on-screen p50/p95/p99 render, - "Export results" button, and the `with-comapeo-bench` config plugin. - Verify with `expo prebuild` followed by both **debug and release** - builds on Android and iOS that the bench bundle is embedded and the - app produces results standalone (with the HTTP toggle off). -4. **Phase 4 (2 days):** Maestro flows (`bench-rpc.yaml` + per-payload-size); - BrowserStack Local tunnel verified with at least three real devices (one - low-end Android, one mid-range Android, one iOS); per-device CSV produced. -5. **Phase 5 (later, when Sentry plan reaches Phase 3):** add - `SentryAdapterSink` implementing the same `recordSpan` interface; the - Sentry plan adopts `boot-spans.js` for the production `backend/index.js`. - No call-site changes here. - -## Out of scope (deferred) - -- `@comapeo/core` API benchmarks (`project.observation.create`, sync, sqlite). - The whole point of the stripped `index.bench.js` is to **avoid** measuring - these. -- Modifications to production `backend/index.js`. Stays untouched until the - Sentry plan adopts the shared helpers. -- Boot timing of the **real** backend including `@comapeo/core` init. We're - measuring the parts we own; full-stack production-feel boot timing is a - separate question (revisit after Sentry plan Phase 3 lands and gives us - that signal automatically). -- Memory benchmarks, sync session throughput, real Sentry transport. - -## Verification - -- **Phase 1:** run the bench backend locally with - `--telemetry=file:/tmp/boot.ndjson`; inspect six `boot.*` spans with sane - durations; confirm `--telemetry=noop` produces no behavioural diff. -- **Phase 2:** run `npm run backend:build` and `npm run backend:build -- --bench`; - confirm `android/src/main/assets/nodejs-project/` and `ios/nodejs-project/` - are unchanged (diff vs main) and that the bench bundle lands at - `android/src/bench/assets/nodejs-project/` + `ios/nodejs-project-bench/` - without `@comapeo/core` artefacts. **Consumer-isolation check:** build - `apps/example/` for Android (both debug and release) and iOS (both - configurations), then unzip the resulting APK / IPA and grep for - `index.bench` and `nodejs-project-bench` — all must be absent. Then run - `expo prebuild` in `apps/benchmark/` and build it for the same four - configurations; confirm the bench bundle IS present in its APK / IPA in - both debug and release. Finally run `npm pack` and inspect the - tarball: no `android/src/bench/` and no `ios/nodejs-project-bench/` - entries. -- **Phase 3 standalone check:** with the device offline (or with the - HTTP toggle off), launch the bench app, run a full sweep, hit - "Export results", and confirm the NDJSON file exists at the displayed - path and the on-screen p50/p95/p99 numbers are populated. Repeat with - a release build to confirm release-mode timings are produced. -- **Phase 3:** run `apps/benchmark` locally on an Android emulator + iOS - simulator with `bench-receiver.ts` listening on `127.0.0.1:`; tap Send - through each payload-size selector; confirm spans arrive (one `op:"boot"` - transaction at launch, `op:"rpc"` spans per Send, with `attrs.bytes` - matching the selected size). -- **Phase 4:** submit to BrowserStack App Automate with three devices; confirm - per-device NDJSON arrives in the receiver and that distinct device tags - appear; eyeball the CSV summary for plausible per-device latency - differences. -- **Phase 5 (when Sentry lands):** flip the sink at one call site and confirm - Sentry dashboards show the same `boot.*` and `op:"rpc"` spans without code - changes elsewhere. From 33118dd4b3a9e93b609e8987b53c6fd58ed2f5f2 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 13:38:32 +0100 Subject: [PATCH 14/33] docs: add bench app README Replaces the deleted plan doc with a focused per-app README covering what the bench measures, how the override hook + plugin + bundle wiring works end-to-end, run instructions for sims/emulators + Maestro flows, and the sink/receiver model. Phase 4/5 status sections leave hooks for the upcoming BrowserStack work. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/benchmark/README.md | 159 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 apps/benchmark/README.md diff --git a/apps/benchmark/README.md b/apps/benchmark/README.md new file mode 100644 index 0000000..cea0a06 --- /dev/null +++ b/apps/benchmark/README.md @@ -0,0 +1,159 @@ +# core-react-native-benchmark + +Measures the `@comapeo/core-react-native` UDS / RPC bridge — boot +phases plus per-payload-size RPC round-trip latency — driving the +real RN→native→nodejs-mobile path with `@comapeo/core` stripped out +so framing / IPC / RPC regressions surface without core noise. + +## What it measures + +- **Boot phases.** `boot.listen-control`, `boot.init`, and + `boot.construct` server-side spans, recorded via a configurable + telemetry sink. (Three more native-side phases — + `ipc-connect (control)`, `rootkey-load`, `ipc-connect (comapeo)` — + will be added when the production loader adopts the same + instrumentation.) +- **RPC round-trip latency** at four payload sizes (64 B / 1 KB / + 64 KB / 1 MB), 10 warmup + 100 steady-state iterations per size. + RN-thread RTT is recorded per request alongside server-side handler + duration so end-to-end vs. server-only timing can be diffed. + +## How it's architected + +The bench app drops a stripped backend bundle into the consumer app's +own native asset tree and tells the module's loader to read from +there. The module sees no bench-specific code: + +- **Module-side override hook.** `@comapeo/core-react-native` exposes + a generic `comapeoBackendDir` config: a Gradle property that + surfaces as `BuildConfig.COMAPEO_BACKEND_DIR` on Android, and a + `ComapeoBackendDir` Info.plist key on iOS. Defaults to + `nodejs-project` (production); `NodeJSService.kt` and + `AppLifecycleDelegate.swift` read it to choose the bundle subdir. +- **Bench plugin.** `plugins/with-comapeo-bench/` is an Expo config + plugin that (a) sets the override to `nodejs-bench`, (b) copies the + rolled-up bench bundle from `backend/dist/` into the consumer app's + own native asset tree at prebuild time — + `android/app/src/main/assets/nodejs-bench/` on Android, an Xcode + folder reference under `.app/nodejs-bench/` on iOS. +- **Bench backend.** `backend/index.js` reuses the production + state machine (`pre-listening` → `started` → `ready`) and + path-imports the framing helpers (`server-helper.js`, + `simple-rpc.js`, `message-port.js`) from the module's production + `backend/lib/` so the wire framing is bit-identical to production. + `BenchRpcServer` (`backend/lib/bench-rpc.js`) registers only `echo` + and `payload(sizeBytes)` methods. Telemetry sinks + (`backend/lib/telemetry-sink.js`) are configurable via + `--telemetry=` on argv: `noop` (default), `file:`, or + `http(s)://`. +- **RN side.** `App.tsx` uses `unstable_messagePort` from + `@comapeo/core-react-native` — a generic escape hatch one level + below the public `comapeo` `MapeoClient` — to send raw frames over + the same JSI → native UDS path real users hit. The bench-specific + request/response schema (`{id, method, params}` vs production's + `{id, jsonrpc, ...}`) means the production RPC machinery treats + bench frames as unknown and ignores them. + +``` +React Native (App.tsx) + │ postMessage({ id, method, params }) + ▼ +unstable_messagePort ← @comapeo/core-react-native + │ + ▼ +JSI bridge → native module → Unix-domain socket pair + │ + ▼ +nodejs-mobile (backend/index.js) + │ + ▼ +BenchRpcServer.dispatch → echo / payload(sizeBytes) +``` + +## Run it + +Prerequisites: Xcode (for iOS) / Android SDK, Node 24, an +iOS simulator or Android emulator booted. + +```bash +cd apps/benchmark +npm install +npm run ios # or: npm run android +``` + +Each platform script runs `prebuild:bundle` first (installs bench +backend deps + rolls up `dist/index.mjs`) and then invokes +`expo run:`. After the app launches, wait for +`Backend → state` to read **STARTED**, optionally toggle payload +sizes, then tap **Run benchmark**. + +Per-size p50 / p95 / p99 render on screen. The full per-RPC NDJSON +is written to the app's Documents directory; tap **Export results** +to share via the system share sheet (iOS) or reveal the path +(Android). + +If you've previously generated `android/` or `ios/` and want a clean +prebuild: + +```bash +rm -rf android ios && npm run prebuild +``` + +## Maestro flows + +```bash +maestro test e2e/.maestro/bench-rpc.yaml # all sizes +maestro test e2e/.maestro/bench-payload-64B.yaml +maestro test e2e/.maestro/bench-payload-1KB.yaml +maestro test e2e/.maestro/bench-payload-64KB.yaml +maestro test e2e/.maestro/bench-payload-1MB.yaml +``` + +Each flow launches the app, asserts `STARTED`, deselects unwanted +sizes, taps `send-button`, and asserts the `benchmark-result` panel +renders with the selected size label. + +To target a specific simulator/emulator when several are booted: + +```bash +maestro --device test e2e/.maestro/bench-rpc.yaml +``` + +## Sinks and the optional receiver + +The on-device JSON file sink (Documents directory) is always written +and is the path of least resistance for ad-hoc local runs. + +The **POST spans** UI toggle additionally fires each RPC span as a +fire-and-forget HTTP POST, default URL +`http://localhost:8787/spans`. This is intended for orchestrated +multi-device runs (see Phase 4 below) where a host-side receiver +collates spans across devices. Failures are silently logged so a +missing receiver never breaks the on-device flow. + +## Phases + +- ✅ **Phase 1–2:** shared sink + bench backend + dual-bundle build + wiring (now: generic `comapeoBackendDir` override + bench-app config + plugin). +- ✅ **Phase 3:** bench app UI, RPC bridge wiring, on-device + p50/p95/p99 render, "Export results", config plugin, Maestro flows. +- ⏳ **Phase 4:** BrowserStack App Automate — multi-device runs across + representative low-end Android, mid-range Android, and iOS hardware, + with span aggregation via BrowserStack Local tunnel + a host-side + receiver. See `scripts/lib/bench-receiver.ts` (forthcoming). +- ⏳ **Phase 5:** `SentryAdapterSink` once the Sentry plan adopts the + shared instrumentation; bench call sites stay the same. + +## Repository layout + +| Path | Role | +|---|---| +| `App.tsx` | Bench UI, RN-side RPC client | +| `app.json` | Registers the `with-comapeo-bench` plugin | +| `backend/index.js` | nodejs-mobile entry, control + comapeo socket bind | +| `backend/lib/bench-rpc.js` | `echo` / `payload(sizeBytes)` RPC dispatch | +| `backend/lib/boot-spans.js` | `boot.` span helper | +| `backend/lib/telemetry-sink.js` | `NoopSink` / `JsonFileSink` / `HttpSink` | +| `backend/rollup.config.js` | Single ESM bundle to `backend/dist/` | +| `plugins/with-comapeo-bench/` | Sets override + copies bundle into prebuild output | From 94b4e530118245f9aa84ccdeeb486af085051cbe Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 5 May 2026 17:49:35 +0100 Subject: [PATCH 15/33] feat(bench): wire BrowserStack runner + host-side span receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Phase 4 plumbing for orchestrated BrowserStack runs: - `scripts/bench-receiver.ts` — minimal localhost HTTP server (no deps, pure node:http). POST /spans appends each span to apps/benchmark/results/.ndjson; runId is path-traversal- guarded against the regex App.tsx generates. GET /health for tunnel verification. - `scripts/run-on-browserstack.ts` — uploads APK / IPA via the Maestro v2 App Automate REST API, zips the bench-*.yaml flows under the `flows/` parent dir BrowserStack requires, uploads the test suite, triggers a build per platform (default device per platform configurable via flags), prints the dashboard URL. Auth and the bench-flows zip are deduplicated via custom_id so re-running with byte-identical artefacts is cheap. Lazy env resolution so `--help` and arg-validation errors don't require credentials. - `e2e/.maestro/bench-rpc-receiver.yaml` — sibling of bench-rpc.yaml that flips the "POST spans" toggle before tapping run, so spans fire to localhost:8787 (reachable from BS devices via BrowserStackLocal). bench-rpc.yaml's stale comment about the removed `comapeoBench` flavor toggle is also refreshed here. - `.env.example` + `.gitignore` updates: credentials live in `.env` (gitignored), receiver output in apps/benchmark/results/ (gitignored), BrowserStackLocal's default log files (browserstack.{err,log}, local.log) gitignored too. - npm scripts: `bench:receiver` and `bench:browserstack` for the per-run workflow documented in apps/benchmark/README.md. Verified offline: receiver accepts valid spans, blocks path-traversal runIds, rejects malformed JSON; runner --help and arg-validation paths render without credentials. Online verification (real upload + build trigger) blocked on BrowserStack account access. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 11 + .gitignore | 17 ++ apps/benchmark/README.md | 61 ++++- e2e/.maestro/bench-rpc-receiver.yaml | 35 +++ e2e/.maestro/bench-rpc.yaml | 19 +- package.json | 4 +- scripts/bench-receiver.ts | 115 +++++++++ scripts/run-on-browserstack.ts | 367 +++++++++++++++++++++++++++ 8 files changed, 616 insertions(+), 13 deletions(-) create mode 100644 .env.example create mode 100644 e2e/.maestro/bench-rpc-receiver.yaml create mode 100644 scripts/bench-receiver.ts create mode 100644 scripts/run-on-browserstack.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..10da752 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# BrowserStack App Automate credentials. Copy this file to .env and +# fill in the values from `Account → Settings → Access Keys` in the +# BrowserStack dashboard. Both scripts in scripts/ read these via +# `node --env-file=.env`. +BROWSERSTACK_USERNAME= +BROWSERSTACK_ACCESS_KEY= + +# Optional: override the bench receiver's listen port (default 8787) +# and output directory (default apps/benchmark/results). +# BENCH_RECEIVER_PORT=8787 +# BENCH_RECEIVER_OUT_DIR=apps/benchmark/results diff --git a/.gitignore b/.gitignore index cdfe0ae..c82e2ec 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,20 @@ build/ # eslint .eslintcache + +# Local secrets (BrowserStack credentials etc). The .example file is +# checked in and lists the variables; copy it to .env and fill in. +.env +.env.local +.env.*.local + +# Bench receiver output. NDJSON spans per run, written by +# scripts/bench-receiver.ts. Treat as build artifacts; copy out of +# this dir if you want to commit a specific run's results. +apps/benchmark/results/ + +# BrowserStackLocal default log files. Created next to wherever the +# binary is invoked from. +browserstack.err +browserstack.log +local.log diff --git a/apps/benchmark/README.md b/apps/benchmark/README.md index cea0a06..a3bd39d 100644 --- a/apps/benchmark/README.md +++ b/apps/benchmark/README.md @@ -138,13 +138,66 @@ missing receiver never breaks the on-device flow. plugin). - ✅ **Phase 3:** bench app UI, RPC bridge wiring, on-device p50/p95/p99 render, "Export results", config plugin, Maestro flows. -- ⏳ **Phase 4:** BrowserStack App Automate — multi-device runs across - representative low-end Android, mid-range Android, and iOS hardware, - with span aggregation via BrowserStack Local tunnel + a host-side - receiver. See `scripts/lib/bench-receiver.ts` (forthcoming). +- 🛠 **Phase 4 (in progress):** BrowserStack App Automate — multi-device + runs across representative Android + iOS hardware, with span + aggregation via BrowserStack Local + a host-side receiver. See + "Run on BrowserStack" below; results-format question still open. - ⏳ **Phase 5:** `SentryAdapterSink` once the Sentry plan adopts the shared instrumentation; bench call sites stay the same. +## Run on BrowserStack + +Three pieces wire together: the host-side **receiver** that collates +spans, the **BrowserStack Local** tunnel that lets the device reach +`localhost`, and the **runner script** that uploads the app + Maestro +flows and triggers a build. + +### One-time setup + +1. BrowserStack App Automate account with the Maestro framework + enabled. Username + access key from `Account → Settings → Access + Keys`. +2. Copy `.env.example` (repo root) to `.env` and fill in + `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY`. +3. Install the + [BrowserStackLocal binary](https://www.browserstack.com/local-testing/app-automate#command-line) + somewhere on `$PATH`. + +### Per-run workflow + +```bash +# 1. Receiver collates incoming spans → apps/benchmark/results/.ndjson +npm run bench:receiver + +# 2. In another shell: tunnel localhost into the BS device fleet +BrowserStackLocal --key "$BROWSERSTACK_ACCESS_KEY" --daemon start +# (optional flags: --local-identifier, --force-local for offline-only) + +# 3. Build a release APK and (Distribution-signed) IPA. Standard +# Expo workflow — check expo docs for IPA signing. + +# 4. Trigger the run (uploads app + Maestro flows, prints dashboard URL) +npm run bench:browserstack -- \ + --app-android path/to/release.apk \ + --app-ios path/to/release.ipa \ + --flow bench-rpc-receiver.yaml \ + --device-android "Samsung Galaxy S23 Ultra-13.0" \ + --device-ios "iPhone 15-17" + +# 5. When done +BrowserStackLocal --key "$BROWSERSTACK_ACCESS_KEY" --daemon stop +``` + +The `bench-rpc-receiver.yaml` flow flips the bench app's "POST spans" +toggle before tapping run; spans land at +`http://localhost:8787/spans` and the receiver appends them to +`apps/benchmark/results/.ndjson`. Use `bench-rpc.yaml` instead +when you only want on-device results visible in the BrowserStack +dashboard. + +The runner deduplicates app + test-suite uploads via `custom_id`, so +re-running with byte-identical artefacts is cheap. + ## Repository layout | Path | Role | diff --git a/e2e/.maestro/bench-rpc-receiver.yaml b/e2e/.maestro/bench-rpc-receiver.yaml new file mode 100644 index 0000000..1b34ca2 --- /dev/null +++ b/e2e/.maestro/bench-rpc-receiver.yaml @@ -0,0 +1,35 @@ +appId: com.comapeo.core.benchmark +--- +# Receiver-enabled variant of bench-rpc.yaml. Same sweep, but flips the +# "POST spans" toggle before running so the bench app fires every +# rpc.payload span to http://localhost:8787/spans (the +# `RECEIVER_DEFAULT_URL` baked into App.tsx). On BrowserStack runs, +# `localhost` from the device's perspective resolves through +# BrowserStackLocal back to the host running scripts/bench-receiver.ts. + +- launchApp: + clearState: true + +- extendedWaitUntil: + visible: + id: "service-state" + text: "STARTED" + timeout: 60000 + +- tapOn: + id: "post-toggle" + +- tapOn: + id: "send-button" + +- extendedWaitUntil: + visible: + id: "benchmark-result" + timeout: 120000 + +- assertVisible: + id: "benchmark-result" +- assertVisible: + text: "p50" +- assertVisible: + text: "p99" diff --git a/e2e/.maestro/bench-rpc.yaml b/e2e/.maestro/bench-rpc.yaml index b0287e6..9ea44d0 100644 --- a/e2e/.maestro/bench-rpc.yaml +++ b/e2e/.maestro/bench-rpc.yaml @@ -1,15 +1,18 @@ appId: com.comapeo.core.benchmark --- # UDS / RPC bridge benchmark — bench app launches its own bench backend -# (`backend/index.bench.js`, embedded via the `bench` Android flavor / -# `ENV['COMAPEO_BENCH']` iOS opt-in), waits for the service to reach -# STARTED, runs a sweep across the default payload sizes, and reads back -# the on-screen p50/p95/p99 panel. Spans are also written to the app's -# documents directory; "Export results" opens the system share sheet. +# (rolled up from `apps/benchmark/backend/`, dropped into the consumer +# app's native asset tree by the `with-comapeo-bench` Expo plugin and +# loaded via the module's `comapeoBackendDir` override), waits for the +# service to reach STARTED, runs a sweep across the default payload +# sizes, and reads back the on-screen p50/p95/p99 panel. Spans are also +# written to the app's documents directory; "Export results" opens the +# system share sheet. # -# This flow is the primary BrowserStack App Automate entry point. For -# orchestrated runs that ship spans to a host-side `bench-receiver.ts`, -# tap the "POST spans" toggle before tapping "Run benchmark". +# Sibling `bench-rpc-receiver.yaml` is the same flow but with the +# "POST spans" toggle enabled, intended for orchestrated BrowserStack +# runs that ship spans to a host-side `bench-receiver.ts` via +# BrowserStack Local. - launchApp: clearState: true diff --git a/package.json b/package.json index 821c212..977545b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "download:nodejs-mobile": "./scripts/download-nodejs-mobile.sh", "backend:install": "npm ci --ignore-scripts --prefix backend", "prebackend:build": "npm run backend:install", - "backend:build": "node ./scripts/build-backend.ts" + "backend:build": "node ./scripts/build-backend.ts", + "bench:receiver": "node ./scripts/bench-receiver.ts", + "bench:browserstack": "node --env-file=.env ./scripts/run-on-browserstack.ts" }, "keywords": [ "react-native", diff --git a/scripts/bench-receiver.ts b/scripts/bench-receiver.ts new file mode 100644 index 0000000..2436b3d --- /dev/null +++ b/scripts/bench-receiver.ts @@ -0,0 +1,115 @@ +/** + * Host-side HTTP receiver for bench spans. + * + * Pairs with the bench app's "POST spans" toggle (default URL + * `http://localhost:8787/spans`). For BrowserStack runs the device + * reaches this through a BrowserStack Local tunnel — start the + * `BrowserStackLocal --key $BROWSERSTACK_ACCESS_KEY --daemon start` + * tunnel and point the bench app at the same localhost URL. + * + * Each span POST is appended to `/.ndjson`. The runId + * is taken from the body the bench app already attaches (`App.tsx` + * generates `${Date.now()}-${random()}` per Run-benchmark tap), so + * spans from a single tap land in a single file across devices that + * happen to share a runId only by collision (vanishingly rare). + * + * Deliberately minimal: no auth, no rate limit, no schema validation + * beyond "has a string runId." Treat it as a localhost development + * tool; don't bind a public interface. + */ + +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import { mkdir, appendFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +const PORT = Number(process.env.BENCH_RECEIVER_PORT ?? 8787); +const OUT_DIR = resolve( + PROJECT_ROOT, + process.env.BENCH_RECEIVER_OUT_DIR ?? "apps/benchmark/results", +); + +await mkdir(OUT_DIR, { recursive: true }); + +let acceptedCount = 0; +let rejectedCount = 0; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolveBody, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +/** + * runId chars are ASCII alphanumerics + dash by construction in + * App.tsx (`${Date.now()}-${random.toString(36)}`). Reject anything + * else so a malformed POST can't write outside `OUT_DIR` via path + * traversal. + */ +function isSafeRunId(s: unknown): s is string { + return typeof s === "string" && s.length > 0 && s.length <= 64 && /^[a-zA-Z0-9_-]+$/.test(s); +} + +const server = createServer(async (req, res) => { + try { + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(`ok accepted=${acceptedCount} rejected=${rejectedCount}\n`); + return; + } + + if (req.method === "POST" && req.url === "/spans") { + const body = await readBody(req); + let span: Record; + try { + span = JSON.parse(body); + } catch { + rejectedCount++; + respondJson(res, 400, { error: "invalid JSON" }); + return; + } + if (!isSafeRunId(span.runId)) { + rejectedCount++; + respondJson(res, 400, { error: "missing or invalid runId" }); + return; + } + const filePath = join(OUT_DIR, `${span.runId}.ndjson`); + await appendFile(filePath, JSON.stringify(span) + "\n"); + acceptedCount++; + respondJson(res, 202, { ok: true }); + return; + } + + respondJson(res, 404, { error: "not found" }); + } catch (e) { + console.error("bench-receiver: handler error", e); + if (!res.headersSent) { + respondJson(res, 500, { error: "internal" }); + } + } +}); + +function respondJson(res: ServerResponse, status: number, body: object) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body) + "\n"); +} + +server.listen(PORT, "127.0.0.1", () => { + console.log( + `bench-receiver listening on http://127.0.0.1:${PORT}\n` + + ` POST /spans → append to ${OUT_DIR}/.ndjson\n` + + ` GET /health → liveness probe`, + ); +}); + +const shutdown = (sig: string) => () => { + console.log(`\nbench-receiver: ${sig} — shutting down (accepted=${acceptedCount} rejected=${rejectedCount})`); + server.close(() => process.exit(0)); +}; +process.on("SIGINT", shutdown("SIGINT")); +process.on("SIGTERM", shutdown("SIGTERM")); diff --git a/scripts/run-on-browserstack.ts b/scripts/run-on-browserstack.ts new file mode 100644 index 0000000..a406a6b --- /dev/null +++ b/scripts/run-on-browserstack.ts @@ -0,0 +1,367 @@ +/** + * Minimal BrowserStack App Automate runner for the bench Maestro + * flows. Uploads the app + the Maestro test suite, triggers a build, + * prints the dashboard URL. + * + * Usage: + * node --env-file=.env scripts/run-on-browserstack.ts \ + * [--app-android ] \ + * [--app-ios ] \ + * [--flow bench-rpc.yaml] \ + * [--device-android "Samsung Galaxy S23 Ultra-13.0"] \ + * [--device-ios "iPhone 15-17"] \ + * [--build-name