diff --git a/.changeset/client-telemetry-native-store.md b/.changeset/client-telemetry-native-store.md new file mode 100644 index 00000000..ed86085c --- /dev/null +++ b/.changeset/client-telemetry-native-store.md @@ -0,0 +1,5 @@ +--- +'@livekit/react-native': minor +--- + +Client telemetry: `registerGlobals` hands `livekit-client`'s telemetry pipeline this platform's resource names, a write-ahead cache backed by a directory of files (`LKBatchStore.swift`, `BatchStore.kt`) so a session that ends with the app being killed replays at the next launch, and the device state a phone can see — app foreground/background, thermal state, low power mode and memory pressure — in the names SPEC uses. `disableTelemetry` is re-exported from `livekit-client`. Requires the first `livekit-client` release that ships the telemetry host seam (`configureTelemetryHost`); the `livekit-client` peer and dev minimum is raised to that release before this ships, and an older `livekit-client` loads without telemetry. diff --git a/android/src/main/java/com/livekit/reactnative/BatchStore.kt b/android/src/main/java/com/livekit/reactnative/BatchStore.kt new file mode 100644 index 00000000..a87491ec --- /dev/null +++ b/android/src/main/java/com/livekit/reactnative/BatchStore.kt @@ -0,0 +1,97 @@ +package com.livekit.reactnative + +import android.content.Context +import java.io.File +import java.io.IOException + +/** + * A directory of batches, mirroring the Rust core's `FileCache` so that a React Native app keeps + * the caching semantics an iOS or Android app gets: a batch is written before the network is + * tried, survives the process, and is removed only once the collector has taken it. + * + * Batch ids sort oldest-first as plain strings, so pruning never has to stat or parse anything. + * Writes go to a temporary name and are renamed into place, so a crash never leaves half a batch + * readable. Eviction is oldest-first above the byte, count and age budgets — and one batch always + * survives, because a cache that prunes itself empty is worse than one that is slightly too big. + */ +class BatchStore( + context: Context, + private val maxBytes: Long, + private val maxBatches: Int, + private val maxAgeMillis: Long, +) { + private val directory = File(context.cacheDir, "livekit-telemetry").apply { mkdirs() } + + init { + directory.listFiles { file -> file.name.endsWith(".tmp") }?.forEach { it.delete() } + prune() + } + + /** + * Stores a batch and returns the ids evicted to stay inside the budgets. Throws when the batch + * could not be written, so a caller never believes a lost batch is stored. + */ + @Throws(IOException::class) + fun put(id: String, body: ByteArray): List { + val destination = file(id) ?: throw IOException("invalid batch id: $id") + // The system may clear cacheDir while the app is not running. + directory.mkdirs() + val temporary = File(directory, "$id.tmp") + try { + temporary.writeBytes(body) + if (!temporary.renameTo(destination)) { + throw IOException("could not move $temporary to $destination") + } + } catch (error: Exception) { + temporary.delete() + throw error + } + return prune() + } + + fun pending(): List = + directory.list()?.filterNot { it.endsWith(".tmp") }?.sorted() ?: emptyList() + + fun read(id: String): ByteArray? = file(id)?.takeIf { it.isFile }?.readBytes() + + fun remove(id: String) { + file(id)?.delete() + } + + /** The batch's file, or null for an id that would escape the directory. */ + private fun file(id: String): File? = + if (id.contains('/') || id.contains("..")) null else File(directory, id) + + fun clear() { + pending().forEach { remove(it) } + } + + /** Drops what is too old, then the oldest until the rest fits. Returns what it dropped. */ + private fun prune(): List { + val evicted = mutableListOf() + val cutoff = System.currentTimeMillis() - maxAgeMillis + val kept = mutableListOf>() + var total = 0L + + for (id in pending()) { + val file = File(directory, id) + if (file.lastModified() < cutoff) { + file.delete() + evicted.add(id) + continue + } + kept.add(id to file.length()) + total += file.length() + } + + var index = 0 + while ((total > maxBytes || kept.size - index > maxBatches) && kept.size - index > 1) { + val (id, bytes) = kept[index] + remove(id) + evicted.add(id) + total -= bytes + index += 1 + } + return evicted + } +} diff --git a/android/src/main/java/com/livekit/reactnative/DeviceStateMonitor.kt b/android/src/main/java/com/livekit/reactnative/DeviceStateMonitor.kt new file mode 100644 index 00000000..dcb66399 --- /dev/null +++ b/android/src/main/java/com/livekit/reactnative/DeviceStateMonitor.kt @@ -0,0 +1,115 @@ +package com.livekit.reactnative + +import android.content.BroadcastReceiver +import android.content.ComponentCallbacks2 +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.os.Build +import android.os.PowerManager +import androidx.annotation.RequiresApi + +/** + * Thermal state, power save mode and memory pressure: the three signals SPEC's cadence policy needs + * and no browser can answer. The pipeline never measures any of them itself — measuring CPU costs + * CPU — so each value here is the OS's own judgement, reported when it changes. + * + * Values are SPEC's names, not Android's, so that a record from Android and a record from iOS say + * the same thing. + */ +class DeviceStateMonitor( + private val context: Context, + private val onChange: (Map) -> Unit, +) { + companion object { + const val EVENT_NAME = "LK_DEVICE_STATE" + + /** `PowerManager.THERMAL_STATUS_*` collapsed onto SPEC's four levels. */ + fun thermalName(status: Int): String = when (status) { + PowerManager.THERMAL_STATUS_NONE -> "nominal" + PowerManager.THERMAL_STATUS_LIGHT -> "fair" + PowerManager.THERMAL_STATUS_MODERATE, PowerManager.THERMAL_STATUS_SEVERE -> "serious" + else -> "critical" + } + + /** + * `onTrimMemory` levels as the Android SDK maps them: COMPLETE and RUNNING_CRITICAL are + * critical, every other trim is a warning. UI_HIDDEN is not a memory signal (it fires on + * every backgrounding) and maps to null. The OS never says pressure is over; JavaScript + * counts it as normal again when the app returns to the foreground. + */ + fun memoryName(level: Int): String? = when (level) { + ComponentCallbacks2.TRIM_MEMORY_COMPLETE, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + -> "critical" + ComponentCallbacks2.TRIM_MEMORY_MODERATE, + ComponentCallbacks2.TRIM_MEMORY_BACKGROUND, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE, + -> "warning" + else -> null + } + } + + private val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + + // Lazy, and touched only behind the SDK_INT >= Q checks below: an eager initializer would + // load a class implementing an API 29 interface in the constructor, which fails to link on + // API 24-28 and takes the whole native module down with it. + @get:RequiresApi(Build.VERSION_CODES.Q) + private val thermalListener by lazy { + PowerManager.OnThermalStatusChangedListener { status -> + onChange(mapOf("thermal" to thermalName(status))) + } + } + + private val powerSaveReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + onChange(mapOf("lowPower" to powerManager.isPowerSaveMode)) + } + } + + private val memoryCallbacks = object : ComponentCallbacks2 { + override fun onTrimMemory(level: Int) { + memoryName(level)?.let { onChange(mapOf("memory" to it)) } + } + override fun onConfigurationChanged(newConfig: Configuration) = Unit + + @Deprecated("Required by ComponentCallbacks2 below API 34") + override fun onLowMemory() = onChange(mapOf("memory" to "critical")) + } + + private var started = false + + fun start() { + if (started) return + started = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + powerManager.addThermalStatusListener(thermalListener) + } + context.registerReceiver( + powerSaveReceiver, + IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED), + ) + context.applicationContext.registerComponentCallbacks(memoryCallbacks) + } + + fun stop() { + if (!started) return + started = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + powerManager.removeThermalStatusListener(thermalListener) + } + runCatching { context.unregisterReceiver(powerSaveReceiver) } + context.applicationContext.unregisterComponentCallbacks(memoryCallbacks) + } + + /** Everything that is a state rather than an edge, for the first report. */ + fun snapshot(): Map = buildMap { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put("thermal", thermalName(powerManager.currentThermalStatus)) + } + put("lowPower", powerManager.isPowerSaveMode) + } +} diff --git a/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt index ece4fcb5..a60d2a73 100644 --- a/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt +++ b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt @@ -1,6 +1,7 @@ package com.livekit.reactnative import android.media.AudioAttributes +import android.util.Base64 import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise @@ -8,6 +9,7 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableArray import com.facebook.react.modules.core.DeviceEventManagerModule import com.livekit.reactnative.audio.AudioDeviceKind import com.livekit.reactnative.audio.AudioManagerUtils @@ -31,6 +33,92 @@ class LivekitReactNativeModule(reactContext: ReactApplicationContext) : ReactCon val audioSinkManager = AudioSinkManager(reactContext) val audioManager = AudioSwitchManager(reactContext.applicationContext) + + /** + * Thermal state, power save mode and memory pressure in SPEC's names, for the cadence policy + * of `livekit-client`'s telemetry pipeline. Nothing is observed until `startDeviceStateUpdates` + * is called, and the observers stop when React Native invalidates the module. + */ + private val deviceStateMonitor = DeviceStateMonitor(reactContext.applicationContext) { change -> + // Delivered on the thermal or broadcast thread, so it can race invalidate(). + if (!reactContext.hasActiveReactInstance()) return@DeviceStateMonitor + val payload = Arguments.createMap() + change.forEach { (key, value) -> + when (value) { + is Boolean -> payload.putBoolean(key, value) + else -> payload.putString(key, value.toString()) + } + } + reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(DeviceStateMonitor.EVENT_NAME, payload) + } + + /** + * The write-ahead cache `livekit-client`'s pipeline stores batches in. Synchronous, because its + * queue path has no await in it; base64 because the bridge does not carry bytes. + * + * SPEC's budget: 4 MiB across at most 512 batches, nothing older than a day. + */ + private val batchStore by lazy { + BatchStore(reactContext.applicationContext, 4L * 1024 * 1024, 512, 24L * 60 * 60 * 1000) + } + + /** + * Answers null, never an empty list, when the batch could not be written; JavaScript turns that + * into a thrown error so the pipeline counts the loss. + */ + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStorePut(id: String, body: String): WritableArray? { + val evicted = try { + batchStore.put(id, Base64.decode(body, Base64.NO_WRAP)) + } catch (error: Exception) { + return null + } + val out = Arguments.createArray() + evicted.forEach { out.pushString(it) } + return out + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStorePending(): WritableArray { + val out = Arguments.createArray() + batchStore.pending().forEach { out.pushString(it) } + return out + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStoreRead(id: String): String? = + batchStore.read(id)?.let { Base64.encodeToString(it, Base64.NO_WRAP) } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStoreRemove(id: String): Boolean { + batchStore.remove(id) + return true + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun batchStoreClear(): Boolean { + batchStore.clear() + return true + } + + /** + * The first state comes back on the promise rather than as an event — an event sent from inside + * this call would race the listener JS is registering around it, and be dropped. + */ + @ReactMethod + fun startDeviceStateUpdates(promise: Promise) { + deviceStateMonitor.start() + val snapshot = Arguments.createMap() + deviceStateMonitor.snapshot().forEach { (key, value) -> + when (value) { + is Boolean -> snapshot.putBoolean(key, value) + else -> snapshot.putString(key, value.toString()) + } + } + promise.resolve(snapshot) + } + override fun getName(): String { return "LivekitReactNativeModule" } @@ -230,6 +318,7 @@ class LivekitReactNativeModule(reactContext: ReactApplicationContext) : ReactCon } override fun invalidate() { + deviceStateMonitor.stop() LiveKitReactNative.invalidate(reactApplicationContext) } } diff --git a/ios/LKBatchStore.swift b/ios/LKBatchStore.swift new file mode 100644 index 00000000..3de890e2 --- /dev/null +++ b/ios/LKBatchStore.swift @@ -0,0 +1,105 @@ +import Foundation + +/** + * A directory of batches, mirroring the Rust core's `FileCache` so that a React Native app keeps + * the caching semantics an iOS or Android app gets: a batch is written before the network is + * tried, survives the process, and is removed only once the collector has taken it. + * + * Batch ids sort oldest-first as plain strings, so pruning never has to stat or parse anything. + * Writes go to a temporary name and are renamed into place, so a crash never leaves half a batch + * readable. Eviction is oldest-first above the byte, count and age budgets — and one batch always + * survives, because a cache that prunes itself empty is worse than one that is slightly too big. + */ +@objc(LKBatchStore) +public class LKBatchStore: NSObject { + private let directory: URL + private let maxBytes: Int + private let maxBatches: Int + private let maxAge: TimeInterval + + @objc public init(maxBytes: Int, maxBatches: Int, maxAgeSeconds: Double) { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + directory = caches.appendingPathComponent("livekit-telemetry", isDirectory: true) + self.maxBytes = maxBytes + self.maxBatches = maxBatches + maxAge = maxAgeSeconds + super.init() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for name in names() where name.hasSuffix(".tmp") { remove(id: name) } + _ = prune() + } + + /// Stores a batch and returns the ids evicted to stay inside the budgets. Throws when the + /// batch could not be written, so a caller never believes a lost batch is stored. + @objc public func put(id: String, body: Data) throws -> [String] { + guard let destination = url(id) else { throw CocoaError(.fileWriteInvalidFileName) } + // The system may purge Caches while the app is not running. + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let temporary = directory.appendingPathComponent("\(id).tmp") + do { + try body.write(to: temporary) + try FileManager.default.moveItem(at: temporary, to: destination) + } catch { + try? FileManager.default.removeItem(at: temporary) + throw error + } + return prune() + } + + @objc public func pending() -> [String] { + names().filter { !$0.hasSuffix(".tmp") }.sorted() + } + + private func names() -> [String] { + (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + } + + @objc public func read(id: String) -> Data? { + url(id).flatMap { try? Data(contentsOf: $0) } + } + + @objc public func remove(id: String) { + if let url = url(id) { try? FileManager.default.removeItem(at: url) } + } + + /// The batch's file, or nil for an id that would escape the directory. + private func url(_ id: String) -> URL? { + id.contains("/") || id.contains("..") ? nil : directory.appendingPathComponent(id) + } + + @objc public func clear() { + for id in pending() { remove(id: id) } + } + + /// Drops what is too old, then the oldest until the rest fits. Returns what it dropped. + private func prune() -> [String] { + var evicted: [String] = [] + var sizes: [(id: String, bytes: Int)] = [] + var total = 0 + let cutoff = Date().addingTimeInterval(-maxAge) + + for id in pending() { + let path = directory.appendingPathComponent(id) + let attributes = try? FileManager.default.attributesOfItem(atPath: path.path) + let modified = attributes?[.modificationDate] as? Date ?? Date() + let bytes = (attributes?[.size] as? NSNumber)?.intValue ?? 0 + if modified < cutoff { + remove(id: id) + evicted.append(id) + continue + } + sizes.append((id, bytes)) + total += bytes + } + + var index = 0 + while (total > maxBytes || sizes.count - index > maxBatches) && sizes.count - index > 1 { + let oldest = sizes[index] + remove(id: oldest.id) + evicted.append(oldest.id) + total -= oldest.bytes + index += 1 + } + return evicted + } +} diff --git a/ios/LKDeviceState.swift b/ios/LKDeviceState.swift new file mode 100644 index 00000000..f2cfae37 --- /dev/null +++ b/ios/LKDeviceState.swift @@ -0,0 +1,94 @@ +import Foundation + +/** + * Thermal state, low power mode and memory pressure: the three signals SPEC's cadence policy needs + * and no browser can answer. The pipeline never measures any of them itself — measuring CPU costs + * CPU — so each value here is the OS's own judgement, reported when it changes. + * + * Values are SPEC's names, not Apple's, so that a record from iOS and a record from Android say the + * same thing. + */ +@objc(LKDeviceState) +public class LKDeviceState: NSObject { + /// One event carrying whatever changed; JS merges it into the state it already has. + @objc public static let eventName = "LK_DEVICE_STATE" + + private let onChange: ([String: Any]) -> Void + private var memorySource: DispatchSourceMemoryPressure? + + @objc public init(onChange: @escaping ([String: Any]) -> Void) { + self.onChange = onChange + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(thermalChanged), + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(powerChanged), + name: Notification.Name.NSProcessInfoPowerStateDidChange, + object: nil + ) + + // DispatchSource reports all three levels, including the return to normal that + // `didReceiveMemoryWarning` never sends. + let source = DispatchSource.makeMemoryPressureSource( + eventMask: [.normal, .warning, .critical], + queue: .main + ) + source.setEventHandler { [weak self, weak source] in + guard let self, let event = source?.data else { return } + self.onChange(["memory": LKDeviceState.memoryName(event)]) + } + source.resume() + memorySource = source + } + + deinit { + stop() + } + + /// Stops observing. Idempotent: the module calls it when React Native invalidates the module, + /// and `deinit` repeats it as a backstop. + @objc public func stop() { + NotificationCenter.default.removeObserver(self) + memorySource?.cancel() + memorySource = nil + } + + /// Everything that is a state rather than an edge, for the first report. + @objc public func snapshot() -> [String: Any] { + let info = ProcessInfo.processInfo + return [ + "thermal": LKDeviceState.thermalName(info.thermalState), + "lowPower": info.isLowPowerModeEnabled, + ] + } + + @objc private func thermalChanged() { + onChange(["thermal": LKDeviceState.thermalName(ProcessInfo.processInfo.thermalState)]) + } + + @objc private func powerChanged() { + onChange(["lowPower": ProcessInfo.processInfo.isLowPowerModeEnabled]) + } + + private static func thermalName(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: return "nominal" + case .fair: return "fair" + case .serious: return "serious" + case .critical: return "critical" + @unknown default: return "nominal" + } + } + + private static func memoryName(_ event: DispatchSource.MemoryPressureEvent) -> String { + if event.contains(.critical) { return "critical" } + if event.contains(.warning) { return "warning" } + return "normal" + } +} diff --git a/ios/LiveKitReactNativeModule.swift b/ios/LiveKitReactNativeModule.swift index 435b339b..9715f725 100644 --- a/ios/LiveKitReactNativeModule.swift +++ b/ios/LiveKitReactNativeModule.swift @@ -7,11 +7,19 @@ struct LKEvents { static let kEventVolumeProcessed = "LK_VOLUME_PROCESSED"; static let kEventMultibandProcessed = "LK_MULTIBAND_PROCESSED"; static let kEventAudioData = "LK_AUDIO_DATA"; + static let kEventDeviceState = LKDeviceState.eventName; } @objc(LivekitReactNativeModule) public class LivekitReactNativeModule: RCTEventEmitter { + private var deviceState: LKDeviceState? = nil + + /// SPEC's cache budget: 4 MiB across at most 512 batches, nothing older than a day. + private lazy var batchStore = LKBatchStore(maxBytes: 4 * 1024 * 1024, + maxBatches: 512, + maxAgeSeconds: 24 * 60 * 60) + // This cannot be initialized in init as self.bridge is given afterwards. private var _audioRendererManager: AudioRendererManager? = nil public var audioRendererManager: AudioRendererManager { @@ -256,11 +264,73 @@ public class LivekitReactNativeModule: RCTEventEmitter { return nil } + /// Thermal state, low power mode and memory pressure in SPEC's names, for the cadence policy + /// of `livekit-client`'s telemetry pipeline. Nothing is observed until this is called, and the + /// observers stop when React Native invalidates the module. + /// + /// The first state comes back on the promise rather than as an event — an event sent from + /// inside this call would race the listener a caller registers around it, and be dropped. + @objc(startDeviceStateUpdates:withRejecter:) + public func startDeviceStateUpdates( + _ resolve: @escaping RCTPromiseResolveBlock, + withRejecter reject: @escaping RCTPromiseRejectBlock + ) { + if deviceState == nil { + deviceState = LKDeviceState { [weak self] change in + self?.sendEvent(withName: LKEvents.kEventDeviceState, body: change) + } + } + resolve(deviceState?.snapshot() ?? [:]) + } + + /// The write-ahead cache `livekit-client`'s pipeline stores batches in. Synchronous, because + /// its queue path has no await in it; base64 because the bridge does not carry bytes. + /// + /// `put` answers `nil`, never an empty list, when the batch could not be written; JavaScript + /// turns that into a thrown error so the pipeline counts the loss. A Swift `throws` cannot + /// cross the bridge. + @objc(batchStorePut:body:) + public func batchStorePut(_ id: String, body: String) -> [String]? { + guard let data = Data(base64Encoded: body) else { return nil } + return try? batchStore.put(id: id, body: data) + } + + @objc(batchStorePending) + public func batchStorePending() -> [String] { + batchStore.pending() + } + + @objc(batchStoreRead:) + public func batchStoreRead(_ id: String) -> String? { + batchStore.read(id: id)?.base64EncodedString() + } + + // A blocking synchronous method must return an *object*: the TurboModule interop retains + // whatever it gets back, so a `Bool` return is read as a pointer and segfaults the app. + @objc(batchStoreRemove:) + public func batchStoreRemove(_ id: String) -> Any? { + batchStore.remove(id: id) + return nil + } + + @objc(batchStoreClear) + public func batchStoreClear() -> Any? { + batchStore.clear() + return nil + } + + override public func invalidate() { + deviceState?.stop() + deviceState = nil + super.invalidate() + } + override public func supportedEvents() -> [String]! { return [ LKEvents.kEventVolumeProcessed, LKEvents.kEventMultibandProcessed, LKEvents.kEventAudioData, + LKEvents.kEventDeviceState, ] } } diff --git a/ios/LivekitReactNativeModule.m b/ios/LivekitReactNativeModule.m index dfe83d6c..057e4f8f 100644 --- a/ios/LivekitReactNativeModule.m +++ b/ios/LivekitReactNativeModule.m @@ -12,6 +12,16 @@ @interface RCT_EXTERN_MODULE(LivekitReactNativeModule, RCTEventEmitter) RCT_EXTERN_METHOD(setDefaultAudioTrackVolume:(nonnull NSNumber *) volume) +RCT_EXTERN_METHOD(startDeviceStateUpdates:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStorePut:(nonnull NSString *)id + body:(nonnull NSString *)body) +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStorePending) +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStoreRead:(nonnull NSString *)id) +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStoreRemove:(nonnull NSString *)id) +RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(batchStoreClear) + RCT_EXTERN_METHOD(showAudioRoutePicker) RCT_EXTERN_METHOD(getAudioOutputsWithResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) diff --git a/package.json b/package.json index 33904f65..f6ef00fa 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "prepare": "bob build", "example": "yarn --cwd example", "pods": "cd example && pod-install --verbose", - "ci:version": "changeset version && yarn build-docs", + "ci:version": "changeset version && node scripts/write-version.js && yarn build-docs", "ci:publish": "changeset publish", "bootstrap": "yarn example && yarn && yarn pods" }, @@ -148,6 +148,7 @@ ] }, "resolutions": { - "react-native-monorepo-config@^0.3.1": "patch:react-native-monorepo-config@npm%3A0.3.1#./.yarn/patches/react-native-monorepo-config-npm-0.3.1-2343b725c3.patch" + "react-native-monorepo-config@^0.3.1": "patch:react-native-monorepo-config@npm%3A0.3.1#./.yarn/patches/react-native-monorepo-config-npm-0.3.1-2343b725c3.patch", + "jest-environment-node": "30.4.1" } } diff --git a/scripts/write-version.js b/scripts/write-version.js new file mode 100644 index 00000000..da3119e0 --- /dev/null +++ b/scripts/write-version.js @@ -0,0 +1,8 @@ +// Keeps src/version.ts in step with package.json after `changeset version`. +const fs = require('fs'); +const path = require('path'); +const { version } = require('../package.json'); +fs.writeFileSync( + path.join(__dirname, '..', 'src', 'version.ts'), + `// Written by scripts/write-version.js after \`changeset version\`; keep in step with package.json.\nexport const version = '${version}';\n` +); diff --git a/src/__tests__/telemetry.test.ts b/src/__tests__/telemetry.test.ts new file mode 100644 index 00000000..430a8f9d --- /dev/null +++ b/src/__tests__/telemetry.test.ts @@ -0,0 +1,249 @@ +import type { TelemetryHost } from 'livekit-client'; + +/** What a `livekit-client` with the host seam exports, as far as this package reads it. */ +const currentClient = () => ({ + configureTelemetryHost: jest.fn(), + disableTelemetry: jest.fn(), + getLogger: jest.fn((name: string) => require('loglevel').getLogger(name)), + setLogLevel: jest.fn(), +}); + +/** A fresh module registry per test: `registerTelemetry` wires once per process. */ +function load(client: object = currentClient()) { + jest.resetModules(); + jest.doMock('livekit-client', () => client); + const { + AppState, + DeviceEventEmitter, + NativeModules, + } = require('react-native'); + const { Platform } = require('react-native'); + const { configureTelemetryHost, getLogger } = require('livekit-client'); + const { registerTelemetry, disableTelemetry } = require('../telemetry'); + const { log } = require('../logger'); + AppState.currentState = 'active'; + jest.spyOn(log, 'warn').mockImplementation(() => {}); + jest.spyOn(log, 'debug').mockImplementation(() => {}); + jest.spyOn(log, 'error').mockImplementation(() => {}); + return { + AppState, + DeviceEventEmitter, + NativeModules, + Platform, + configureTelemetryHost, + getLogger, + registerTelemetry, + disableTelemetry, + log, + }; +} + +describe('the package logger', () => { + it('is the instance livekit-client registers, and still takes every argument', () => { + const { log, getLogger } = load(); + expect(getLogger).toHaveBeenCalledWith('lk-react-native'); + expect(log).toBe(getLogger.mock.results[0].value); + expect(log.getLevel()).toBe(log.levels.WARN); + const warn = jest.spyOn(log, 'warn').mockImplementation(() => {}); + log.warn('audio session', { reason: 'busy' }, 3); + expect(warn).toHaveBeenCalledWith('audio session', { reason: 'busy' }, 3); + }); +}); + +/** The native module as the bridge presents it, with every answer under the test's control. */ +function mockNative(NativeModules: any) { + const native = { + addListener: jest.fn(), + removeListeners: jest.fn(), + startDeviceStateUpdates: jest.fn().mockResolvedValue({ + thermal: 'nominal', + lowPower: false, + }), + batchStorePut: jest.fn().mockReturnValue([]), + batchStorePending: jest.fn().mockReturnValue([]), + batchStoreRead: jest.fn().mockReturnValue(null), + batchStoreRemove: jest.fn(), + batchStoreClear: jest.fn(), + }; + NativeModules.LivekitReactNativeModule = native; + return native; +} + +const hostOf = (configureTelemetryHost: jest.Mock): TelemetryHost => + configureTelemetryHost.mock.calls[0][0]; + +describe('registerTelemetry', () => { + it('wires the host once, naming this platform', () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + mockNative(NativeModules); + registerTelemetry(); + registerTelemetry(); + expect(configureTelemetryHost).toHaveBeenCalledTimes(1); + const host = hostOf(configureTelemetryHost); + expect(host.sdk).toMatchObject({ + name: 'react-native', + version: require('../../package.json').version, + os: 'ios', + }); + expect(host.storage).toBeDefined(); + expect(host.observeDevice).toBeDefined(); + expect(host.endpoint).toBeUndefined(); + }); + + it('still configures without native modules: no cache, app state only', () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + delete NativeModules.LivekitReactNativeModule; + registerTelemetry(); + const host = hostOf(configureTelemetryHost); + expect(host.storage).toBeUndefined(); + const report = jest.fn(); + const stop = host.observeDevice!(report, jest.fn()); + expect(report).toHaveBeenCalledWith({ appState: 'foreground' }); + expect(() => stop?.()).not.toThrow(); + }); + + it('degrades to no telemetry on a livekit-client without the host seam', () => { + const { registerTelemetry, disableTelemetry, log } = load({ + getLogger: currentClient().getLogger, + setLogLevel: jest.fn(), + }); + expect(() => registerTelemetry()).not.toThrow(); + expect(() => disableTelemetry()).not.toThrow(); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('forwards disableTelemetry to livekit-client', () => { + const client = currentClient(); + load(client).disableTelemetry(); + expect(client.disableTelemetry).toHaveBeenCalledTimes(1); + }); + + it('reports a failing hook once at debug, outside the captured warn path', () => { + const { registerTelemetry, configureTelemetryHost, log } = load(); + configureTelemetryHost.mockImplementation(() => { + throw new Error('boom'); + }); + expect(() => registerTelemetry()).not.toThrow(); + registerTelemetry(); + expect(configureTelemetryHost).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledTimes(1); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + }); +}); + +describe('the host storage', () => { + it('carries bytes as base64 and evicted ids back', () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + const native = mockNative(NativeModules); + native.batchStorePut.mockReturnValue(['000-old']); + native.batchStorePending.mockReturnValue(['000-old', '001-new']); + native.batchStoreRead.mockImplementation((id: string) => + id === '001-new' ? 'AQID' : null + ); + registerTelemetry(); + const store = hostOf(configureTelemetryHost).storage!; + + expect(store.put('001-new', new Uint8Array([1, 2, 3]))).toEqual([ + '000-old', + ]); + expect(native.batchStorePut).toHaveBeenCalledWith('001-new', 'AQID'); + expect(store.pending()).toEqual(['000-old', '001-new']); + expect(store.read('001-new')).toEqual(new Uint8Array([1, 2, 3])); + expect(store.read('nope')).toBeUndefined(); + store.remove('000-old'); + store.clear(); + expect(native.batchStoreRemove).toHaveBeenCalledWith('000-old'); + expect(native.batchStoreClear).toHaveBeenCalled(); + }); + + it('turns a native write failure into a throw, so the pipeline counts the loss', () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + mockNative(NativeModules).batchStorePut.mockReturnValue(null); + registerTelemetry(); + const store = hostOf(configureTelemetryHost).storage!; + // What the pipeline does: a put that throws is a counted loss; one that returns is stored. + let lost = 0; + try { + store.put('001', new Uint8Array([1])); + } catch { + lost += 1; + } + expect(lost).toBe(1); + }); +}); + +describe('the host device observer', () => { + it('reports app state, the native snapshot, then each change, until stopped', async () => { + const { + registerTelemetry, + configureTelemetryHost, + NativeModules, + AppState, + DeviceEventEmitter, + } = load(); + mockNative(NativeModules); + registerTelemetry(); + const report = jest.fn(); + const stop = hostOf(configureTelemetryHost).observeDevice!( + report, + jest.fn() + ); + await Promise.resolve(); + expect(report.mock.calls.map((c) => c[0])).toEqual([ + { appState: 'foreground' }, + { thermal: 'nominal', lowPower: false }, + ]); + + DeviceEventEmitter.emit('LK_DEVICE_STATE', { memory: 'critical' }); + const onAppState = AppState.addEventListener.mock.calls[0][1]; + onAppState('background'); + onAppState('inactive'); + expect(report.mock.calls.slice(2).map((c) => c[0])).toEqual([ + { memory: 'critical' }, + { appState: 'background' }, + { appState: 'foreground' }, + ]); + + stop?.(); + DeviceEventEmitter.emit('LK_DEVICE_STATE', { thermal: 'serious' }); + expect(report).toHaveBeenCalledTimes(5); + expect( + AppState.addEventListener.mock.results[0].value.remove + ).toHaveBeenCalled(); + }); + + it('counts Android memory as normal again when the app returns to the foreground', () => { + const { + registerTelemetry, + configureTelemetryHost, + NativeModules, + AppState, + Platform, + } = load(); + mockNative(NativeModules); + registerTelemetry(); + jest.replaceProperty(Platform, 'OS', 'android'); + const report = jest.fn(); + hostOf(configureTelemetryHost).observeDevice!(report, jest.fn()); + const onAppState = AppState.addEventListener.mock.calls[0][1]; + onAppState('background'); + expect(report).toHaveBeenLastCalledWith({ appState: 'background' }); + onAppState('active'); + expect(report).toHaveBeenLastCalledWith({ + appState: 'foreground', + memory: 'normal', + }); + }); + + it('drops a native snapshot that lands after stop', async () => { + const { registerTelemetry, configureTelemetryHost, NativeModules } = load(); + mockNative(NativeModules); + registerTelemetry(); + const report = jest.fn(); + hostOf(configureTelemetryHost).observeDevice!(report, jest.fn())?.(); + await Promise.resolve(); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith({ appState: 'foreground' }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 3d5ed530..2d9bc489 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,6 +31,7 @@ import type { LogLevel, SetLogLevelOptions } from './logger'; import RNE2EEManager from './e2ee/RNE2EEManager'; import RNKeyProvider, { type RNKeyProviderOptions } from './e2ee/RNKeyProvider'; import { setupNativeEvents } from './events/EventEmitter'; +import { registerTelemetry } from './telemetry'; import { ReadableStream, WritableStream, @@ -74,6 +75,7 @@ export function registerGlobals(options?: RegisterGlobalsOptions) { shimCryptoUuid(); shimWebstreams(); setupNativeEvents(); + registerTelemetry(); } function livekitRegisterGlobals() { @@ -168,6 +170,7 @@ export * from './logger'; export * from './audio/AudioManager'; export * from './audio/AudioManagerLegacy'; export * from './audio/MediaRecorder'; +export { disableTelemetry } from './telemetry'; export { AudioSession, diff --git a/src/logger.ts b/src/logger.ts index 9f6fa629..8dfb2c54 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,8 @@ -import { setLogLevel as setClientSdkLogLevel } from 'livekit-client'; -import loglevel from 'loglevel'; +import { getLogger, setLogLevel as setClientSdkLogLevel } from 'livekit-client'; -export const log = loglevel.getLogger('lk-react-native'); +// Registered through `livekit-client`, so this package's warnings and errors reach client +// telemetry the way `livekit-client`'s own do, whatever the console level. +export const log = getLogger('lk-react-native'); log.setDefaultLevel('WARN'); export type LogLevel = Parameters[0]; diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 00000000..11da29c1 --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,149 @@ +// A namespace import read at call time: a `livekit-client` older than the host seam then loads +// without a missing-export error and simply has no telemetry. +import * as client from 'livekit-client'; +import type { TelemetryDeviceState, TelemetryHost } from 'livekit-client'; +import { + AppState, + type AppStateStatus, + NativeEventEmitter, + NativeModules, + Platform, +} from 'react-native'; +import { log } from './logger'; +import { version } from './version'; +import { nativeBatchStore } from './telemetryStorage'; + +/** + * The React Native half of client telemetry. + * + * `livekit-client` owns the pipeline and the instrumentation — when a connect span starts, what a + * stats window holds, how a subscribe ends at first media — and this package reuses it unchanged. + * What it adds is what a phone knows and a browser does not: a filesystem to cache batches in, and + * a device that goes to the background, gets hot, runs low on memory and asks for less work. + */ + +/** What the native side reports, already in SPEC's names; each event carries only what changed. */ +export interface NativeDeviceState { + thermal?: 'nominal' | 'fair' | 'serious' | 'critical'; + lowPower?: boolean; + memory?: 'normal' | 'warning' | 'critical'; +} + +const DEVICE_STATE_EVENT = 'LK_DEVICE_STATE'; + +/** + * Starts the native thermal, low power and memory pressure observers and forwards every change. + * The first report is the current state; the rest are deltas. Returns a function that stops the + * forwarding. An app on an older native build gets no reports, and must not crash. + */ +export function startDeviceState( + onChange: (change: NativeDeviceState) => void +): () => void { + const native = NativeModules.LivekitReactNativeModule; + if (!native?.startDeviceStateUpdates) { + return () => {}; + } + let active = true; + const subscription = new NativeEventEmitter(native).addListener( + DEVICE_STATE_EVENT, + onChange + ); + // The first state is the promise's answer, not an event: an event sent while this call is still + // in flight would arrive before the listener above is registered natively, and be dropped. + native + .startDeviceStateUpdates() + .then((state: NativeDeviceState) => { + if (active) { + onChange(state); + } + }) + .catch(() => {}); + return () => { + active = false; + subscription.remove(); + }; +} + +/** + * Everything this platform can tell the pipeline about the device: the app leaving and entering the + * foreground (the pipeline flushes on background itself), plus the native thermal, low power and + * memory state. iOS reports `inactive` for a pulled-down notification shade, which is not the + * background. Network, battery and audio route are not observed — nothing in this package's + * dependencies exposes them. + */ +function observeDevice( + report: (state: TelemetryDeviceState) => void +): () => void { + const appState = (status: AppStateStatus): TelemetryDeviceState => ({ + appState: status === 'background' ? 'background' : 'foreground', + // Android never says memory pressure is over; as the Android SDK does, it counts as normal + // again when the app returns to the foreground. iOS reports the return itself. + ...(Platform.OS === 'android' && status === 'active' + ? { memory: 'normal' as const } + : {}), + }); + const app = AppState.addEventListener('change', (status) => + report(appState(status)) + ); + const stopNative = startDeviceState(report); + report(appState(AppState.currentState)); + return () => { + app.remove(); + stopNative(); + }; +} + +let configured = false; + +/** + * Hands `livekit-client`'s telemetry pipeline this platform's resource names, file cache and device + * state. Called by {@link registerGlobals}; wired once, and a failure is logged once and dropped so + * telemetry never stops an app from starting. `endpoint` is for local runs against a collector + * (see the PR's Local testing) and re-applies the host. + */ +export function registerTelemetry(options: { endpoint?: string } = {}) { + if (configured && options.endpoint === undefined) { + return; + } + configured = true; + if (!client.configureTelemetryHost) { + return; + } + try { + const host: TelemetryHost = { + sdk: { + name: 'react-native', + version, + os: Platform.OS, + osVersion: String(Platform.Version), + // iOS has no model identifier in `Platform.constants`; left to the resource's defaults. + deviceModel: + Platform.OS === 'android' ? Platform.constants.Model : undefined, + }, + storage: nativeBatchStore(), + observeDevice, + endpoint: options.endpoint, + }; + client.configureTelemetryHost(host); + } catch (error) { + // Debug, as livekit-client reports its own telemetry failures: warn and error are captured + // into telemetry, and a telemetry failure must not report itself. A throwing log extension + // is swallowed too. + try { + log.debug('client telemetry is not configured on this platform', error); + } catch { + // never into the app + } + } +} + +/** + * Opts this process out of client telemetry: `livekit-client`'s `disableTelemetry`, a no-op on a + * `livekit-client` without it. In effect when the call returns; call it on every start before + * the first Room. + * + * TODO: final shape pending the token/consent discussion. + */ +export function disableTelemetry(): void { + client.disableTelemetry?.(); +} diff --git a/src/telemetryStorage.ts b/src/telemetryStorage.ts new file mode 100644 index 00000000..b146a756 --- /dev/null +++ b/src/telemetryStorage.ts @@ -0,0 +1,61 @@ +import { fromByteArray, toByteArray } from 'base64-js'; +import { NativeModules } from 'react-native'; + +/** + * The write-ahead cache `livekit-client`'s pipeline stores batches in: the shape the Rust core's + * `FileCache` has, so a React Native app keeps the caching semantics an iOS or Android app gets. + * A batch survives the process and is removed only once the collector has taken it. + * + * TODO(phase 2): replace with the type `livekit-client` exports once its storage hook lands. + */ +export interface TelemetryStorage { + /** + * Stores a batch and returns the ids evicted to stay inside the budgets. Throws when the batch + * could not be written, so the pipeline counts the loss instead of believing it stored. + */ + put(id: string, body: Uint8Array): string[]; + /** Ids of the stored batches, oldest first. */ + pending(): string[]; + read(id: string): Uint8Array | undefined; + remove(id: string): void; + clear(): void; +} + +/** + * The native file cache, or `undefined` on a native build that predates it — such an app simply + * keeps its batches in memory. + * + * The calls are synchronous blocking bridge calls because the pipeline's queue path has no await + * in it, and base64 because the bridge does not carry bytes. That is also why this is native code + * in this package rather than one of the filesystem packages on npm — all of those are async. + */ +export function nativeBatchStore(): TelemetryStorage | undefined { + const native = NativeModules.LivekitReactNativeModule; + if (!native?.batchStorePut) { + return undefined; + } + return { + put: (id, body) => { + // The native store answers null, never an empty list, when it could not write the batch. + const evicted: string[] | null = native.batchStorePut( + id, + fromByteArray(body) + ); + if (!evicted) { + throw new Error(`telemetry cache could not write batch ${id}`); + } + return evicted; + }, + pending: () => native.batchStorePending() ?? [], + read: (id) => { + const body = native.batchStoreRead(id); + return body ? toByteArray(body) : undefined; + }, + remove: (id) => { + native.batchStoreRemove(id); + }, + clear: () => { + native.batchStoreClear(); + }, + }; +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..8dc6a881 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,2 @@ +// Written by scripts/write-version.js after `changeset version`; keep in step with package.json. +export const version = '3.0.0'; diff --git a/tsconfig.build.json b/tsconfig.build.json index 9adfc508..7a1a0170 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig", - "exclude": ["example", "ci"] + "exclude": ["example", "ci", "**/__tests__"] } diff --git a/yarn.lock b/yarn.lock index c91af92e..7c0ca96d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41,17 +41,6 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/code-frame@npm:7.29.7" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.29.7" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.1.1" - checksum: 10/84da552e51a55795a50b3589116edb2f9e368a647d266380683775f18effd9acd4521b0246bebd0b049a7f32af1f87b1e8475d3bcb665f876bd04ade8da99697 - languageName: node - linkType: hard - "@babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -74,6 +63,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.29.7" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10/84da552e51a55795a50b3589116edb2f9e368a647d266380683775f18effd9acd4521b0246bebd0b049a7f32af1f87b1e8475d3bcb665f876bd04ade8da99697 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.24.7": version: 7.24.7 resolution: "@babel/compat-data@npm:7.24.7" @@ -3197,18 +3197,6 @@ __metadata: languageName: node linkType: hard -"@jest/environment@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/environment@npm:29.7.0" - dependencies: - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - checksum: 10/90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 - languageName: node - linkType: hard - "@jest/expect-utils@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect-utils@npm:30.4.1" @@ -3242,20 +3230,6 @@ __metadata: languageName: node linkType: hard -"@jest/fake-timers@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/fake-timers@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@sinonjs/fake-timers": "npm:^10.0.2" - "@types/node": "npm:*" - jest-message-util: "npm:^29.7.0" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 - languageName: node - linkType: hard - "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -4485,7 +4459,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": +"@sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -4494,15 +4468,6 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^10.0.2": - version: 10.3.0 - resolution: "@sinonjs/fake-timers@npm:10.3.0" - dependencies: - "@sinonjs/commons": "npm:^3.0.0" - checksum: 10/78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e - languageName: node - linkType: hard - "@sinonjs/fake-timers@npm:^15.4.0": version: 15.4.0 resolution: "@sinonjs/fake-timers@npm:15.4.0" @@ -4669,7 +4634,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10/72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 @@ -8840,20 +8805,6 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-environment-node@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 - languageName: node - linkType: hard - "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -8946,23 +8897,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-message-util@npm:29.7.0" - dependencies: - "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^29.6.3" - "@types/stack-utils": "npm:^2.0.0" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10/31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 - languageName: node - linkType: hard - "jest-mock@npm:30.4.1": version: 30.4.1 resolution: "jest-mock@npm:30.4.1" @@ -8974,17 +8908,6 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-mock@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-util: "npm:^29.7.0" - checksum: 10/ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c - languageName: node - linkType: hard - "jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" @@ -12189,7 +12112,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: