@barrysolomon/mobile-react-native 0.1.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/Dash0Mobile.podspec +29 -0
  2. package/README.md +117 -0
  3. package/android/build.gradle +68 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +67 -0
  6. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +198 -0
  7. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +14 -0
  8. package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +208 -0
  9. package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +413 -0
  10. package/ios/BridgeCallSink.swift +83 -0
  11. package/ios/Dash0MobileBridgeDispatcher.swift +142 -0
  12. package/ios/OTelMobileCallSink.swift +262 -0
  13. package/ios/RCTDash0MobileModule.m +28 -0
  14. package/ios/RCTDash0MobileModule.swift +104 -0
  15. package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +341 -0
  16. package/lib/src/NativeDash0Mobile.d.ts +27 -0
  17. package/lib/src/NativeDash0Mobile.d.ts.map +1 -0
  18. package/lib/src/NativeDash0Mobile.js +19 -0
  19. package/lib/src/bridge/NativeBridge.d.ts +38 -0
  20. package/lib/src/bridge/NativeBridge.d.ts.map +1 -0
  21. package/lib/src/bridge/NativeBridge.js +95 -0
  22. package/lib/src/bridge/types.d.ts +166 -0
  23. package/lib/src/bridge/types.d.ts.map +1 -0
  24. package/lib/src/bridge/types.js +10 -0
  25. package/lib/src/index.d.ts +35 -0
  26. package/lib/src/index.d.ts.map +1 -0
  27. package/lib/src/index.js +408 -0
  28. package/lib/src/instrumentation/errors.d.ts +14 -0
  29. package/lib/src/instrumentation/errors.d.ts.map +1 -0
  30. package/lib/src/instrumentation/errors.js +65 -0
  31. package/lib/src/instrumentation/fetch.d.ts +16 -0
  32. package/lib/src/instrumentation/fetch.d.ts.map +1 -0
  33. package/lib/src/instrumentation/fetch.js +75 -0
  34. package/lib/src/instrumentation/navigation.d.ts +19 -0
  35. package/lib/src/instrumentation/navigation.d.ts.map +1 -0
  36. package/lib/src/instrumentation/navigation.js +39 -0
  37. package/lib/src/instrumentation/touch.d.ts +12 -0
  38. package/lib/src/instrumentation/touch.d.ts.map +1 -0
  39. package/lib/src/instrumentation/touch.js +18 -0
  40. package/lib/src/instrumentation/unhandledRejection.d.ts +9 -0
  41. package/lib/src/instrumentation/unhandledRejection.d.ts.map +1 -0
  42. package/lib/src/instrumentation/unhandledRejection.js +52 -0
  43. package/lib/src/instrumentation/xhr.d.ts +14 -0
  44. package/lib/src/instrumentation/xhr.d.ts.map +1 -0
  45. package/lib/src/instrumentation/xhr.js +88 -0
  46. package/lib/src/otel-compat.d.ts +67 -0
  47. package/lib/src/otel-compat.d.ts.map +1 -0
  48. package/lib/src/otel-compat.js +84 -0
  49. package/package.json +72 -0
  50. package/react-native.config.js +17 -0
  51. package/src/NativeDash0Mobile.ts +29 -0
  52. package/src/bridge/NativeBridge.ts +101 -0
  53. package/src/bridge/types.ts +188 -0
  54. package/src/index.ts +456 -0
  55. package/src/instrumentation/errors.ts +84 -0
  56. package/src/instrumentation/fetch.ts +93 -0
  57. package/src/instrumentation/navigation.ts +52 -0
  58. package/src/instrumentation/touch.ts +32 -0
  59. package/src/instrumentation/unhandledRejection.ts +75 -0
  60. package/src/instrumentation/xhr.ts +125 -0
  61. package/src/otel-compat.ts +159 -0
@@ -0,0 +1,341 @@
1
+ // RN-013 unit tests for Dash0MobileBridgeDispatcher.
2
+ //
3
+ // Uses Swift Testing (aligns with the rest of otel-ios-mobile). These tests
4
+ // drive the dispatcher through the same payload fixtures the Android module
5
+ // tests use, to keep the cross-platform bridge contract aligned.
6
+
7
+ import Testing
8
+ @testable import Dash0MobileReactNative
9
+
10
+ @Suite("Dash0MobileBridgeDispatcher")
11
+ struct Dash0MobileBridgeDispatcherTests {
12
+
13
+ // ── start ─────────────────────────────────────────────────────────────
14
+
15
+ @Test
16
+ func start_forwards_all_fields() throws {
17
+ let sink = RecordingSink()
18
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
19
+ try d.start(config: [
20
+ "serviceName": "otel-rn-astronomy-shop",
21
+ "endpoint": "https://ingress/v1/logs",
22
+ "serviceVersion": "1.2.3",
23
+ "authToken": "tok",
24
+ "dataset": "otel-mobile",
25
+ ])
26
+ #expect(sink.starts.count == 1)
27
+ let got = sink.starts[0]
28
+ #expect(got.serviceName == "otel-rn-astronomy-shop")
29
+ #expect(got.endpoint == "https://ingress/v1/logs")
30
+ #expect(got.serviceVersion == "1.2.3")
31
+ #expect(got.authToken == "tok")
32
+ #expect(got.dataset == "otel-mobile")
33
+ }
34
+
35
+ @Test
36
+ func start_forwards_nativeAutoCapture_tokens() throws {
37
+ let sink = RecordingSink()
38
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
39
+ try d.start(config: [
40
+ "serviceName": "s",
41
+ "endpoint": "https://e",
42
+ "nativeAutoCapture": ["vitals", "deviceStats"],
43
+ ])
44
+ #expect(sink.starts.count == 1)
45
+ #expect(sink.starts[0].nativeAutoCapture == ["vitals", "deviceStats"])
46
+ }
47
+
48
+ @Test
49
+ func start_defaults_nativeAutoCapture_to_empty_when_absent() throws {
50
+ let sink = RecordingSink()
51
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
52
+ try d.start(config: [
53
+ "serviceName": "s",
54
+ "endpoint": "https://e",
55
+ ])
56
+ #expect(sink.starts[0].nativeAutoCapture == [])
57
+ }
58
+
59
+ @Test
60
+ func start_throws_when_serviceName_missing() {
61
+ let sink = RecordingSink()
62
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
63
+ #expect(throws: Dash0MobileBridgeError.self) {
64
+ try d.start(config: ["endpoint": "e"])
65
+ }
66
+ #expect(sink.starts.isEmpty)
67
+ }
68
+
69
+ // ── emitBatch: log ────────────────────────────────────────────────────
70
+
71
+ @Test
72
+ func emitBatch_dispatches_log() {
73
+ let sink = RecordingSink()
74
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
75
+ d.emitBatch([[
76
+ "kind": "log",
77
+ "name": "cart.add_item",
78
+ "severity": 9,
79
+ "timeUnixNano": "1713600000000000000",
80
+ "attributes": ["shop.item_id": "abc", "qty": 2, "urgent": true],
81
+ ]])
82
+ #expect(sink.logs.count == 1)
83
+ let l = sink.logs[0]
84
+ #expect(l.name == "cart.add_item")
85
+ #expect(l.severity == 9)
86
+ #expect(l.timeUnixNano == 1_713_600_000_000_000_000)
87
+ #expect(l.attributes["shop.item_id"] as? String == "abc")
88
+ #expect(l.attributes["urgent"] as? Bool == true)
89
+ }
90
+
91
+ // ── emitBatch: span pair ──────────────────────────────────────────────
92
+
93
+ @Test
94
+ func emitBatch_dispatches_spanStart_then_spanEnd() {
95
+ let sink = RecordingSink()
96
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
97
+ d.emitBatch([
98
+ [
99
+ "kind": "spanStart",
100
+ "spanId": "abc123def456789a",
101
+ "name": "checkout",
102
+ "spanKind": "INTERNAL",
103
+ "startTimeUnixNano": "0",
104
+ "attributes": ["shop.cart_size": 3],
105
+ ],
106
+ [
107
+ "kind": "spanEnd",
108
+ "spanId": "abc123def456789a",
109
+ "status": "OK",
110
+ "endTimeUnixNano": "50",
111
+ "attributes": ["http.response.status_code": 200],
112
+ ],
113
+ ])
114
+ #expect(sink.spanStarts.count == 1)
115
+ #expect(sink.spanEnds.count == 1)
116
+ #expect(sink.spanStarts[0].spanId == sink.spanEnds[0].spanId)
117
+ #expect(sink.spanEnds[0].status == "OK")
118
+ }
119
+
120
+ @Test
121
+ func emitBatch_dispatches_ERROR_with_statusMessage() {
122
+ let sink = RecordingSink()
123
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
124
+ d.emitBatch([[
125
+ "kind": "spanEnd",
126
+ "spanId": "aaaaaaaaaaaaaaaa",
127
+ "status": "ERROR",
128
+ "statusMessage": "nope",
129
+ "endTimeUnixNano": "0",
130
+ "attributes": [:],
131
+ ]])
132
+ #expect(sink.spanEnds[0].status == "ERROR")
133
+ #expect(sink.spanEnds[0].statusMessage == "nope")
134
+ }
135
+
136
+ // ── emitBatch: metric ─────────────────────────────────────────────────
137
+
138
+ @Test
139
+ func emitBatch_dispatches_counter_histogram_gauge() {
140
+ let sink = RecordingSink()
141
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
142
+ for t in ["counter", "histogram", "gauge"] {
143
+ d.emitBatch([[
144
+ "kind": "metric",
145
+ "name": "shop.x",
146
+ "instrumentType": t,
147
+ "value": 42.5,
148
+ "timeUnixNano": "0",
149
+ "attributes": [:],
150
+ ]])
151
+ }
152
+ #expect(sink.metrics.count == 3)
153
+ #expect(sink.metrics.map { $0.instrumentType } == ["counter", "histogram", "gauge"])
154
+ #expect(sink.metrics[0].value == 42.5)
155
+ }
156
+
157
+ // ── forward-compat ────────────────────────────────────────────────────
158
+
159
+ @Test
160
+ func emitBatch_silently_drops_unknown_kind() {
161
+ let sink = RecordingSink()
162
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
163
+ d.emitBatch([["kind": "martian", "name": "x"]])
164
+ #expect(sink.logs.isEmpty)
165
+ #expect(sink.metrics.isEmpty)
166
+ #expect(sink.spanStarts.isEmpty)
167
+ #expect(sink.spanEnds.isEmpty)
168
+ }
169
+
170
+ // ── flush / shutdown ──────────────────────────────────────────────────
171
+
172
+ @Test
173
+ func flushWindow_forwards_rounded_minutes() {
174
+ let sink = RecordingSink()
175
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
176
+ d.flushWindow(minutes: 5.0)
177
+ #expect(sink.flushMinutes == [5])
178
+ }
179
+
180
+ @Test
181
+ func shutdown_forwards() {
182
+ let sink = RecordingSink()
183
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
184
+ d.shutdown()
185
+ #expect(sink.shutdowns == 1)
186
+ }
187
+
188
+ // MARK: - FATAL-severity forceFlush hook
189
+
190
+ @Test
191
+ func emitLog_fatalSeverity_triggersForceFlush() {
192
+ let sink = RecordingSink()
193
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
194
+ d.emitBatch([[
195
+ "kind": "log",
196
+ "name": "app.error",
197
+ "severity": 21,
198
+ "attributes": [:],
199
+ "timeUnixNano": "1700000000000000000"
200
+ ]])
201
+ #expect(sink.logs.count == 1)
202
+ #expect(sink.forceFlushes == 1)
203
+ }
204
+
205
+ @Test
206
+ func emitLog_belowFatal_doesNotTriggerForceFlush() {
207
+ let sink = RecordingSink()
208
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
209
+ // 17 = ERROR in OTel semconv. Should NOT flush.
210
+ d.emitBatch([[
211
+ "kind": "log",
212
+ "name": "app.error",
213
+ "severity": 17,
214
+ "attributes": [:],
215
+ "timeUnixNano": "1700000000000000000"
216
+ ]])
217
+ #expect(sink.logs.count == 1)
218
+ #expect(sink.forceFlushes == 0)
219
+ }
220
+
221
+ @Test
222
+ func emitLog_fatalSeverity_flushOrderingIsPostEmitPrePeer() {
223
+ let sink = RecordingSink()
224
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
225
+ // FATAL log followed by another payload in the same batch. The
226
+ // dispatcher must emit the FATAL, force-flush, THEN dispatch
227
+ // the next payload — never the other way around. This preserves
228
+ // the invariant that the FATAL gets out of the RAM buffer before
229
+ // the next bridge call could clobber it (or the process dies).
230
+ d.emitBatch([
231
+ [
232
+ "kind": "log",
233
+ "name": "app.error",
234
+ "severity": 21,
235
+ "attributes": [:],
236
+ "timeUnixNano": "1700000000000000000"
237
+ ],
238
+ [
239
+ "kind": "log",
240
+ "name": "app.error.context",
241
+ "severity": 9,
242
+ "attributes": [:],
243
+ "timeUnixNano": "1700000000000000001"
244
+ ]
245
+ ])
246
+ // Expected order: emit FATAL, forceFlush, emit context.
247
+ #expect(sink.actionLog == [
248
+ "emitLog(app.error,21)",
249
+ "forceFlush",
250
+ "emitLog(app.error.context,9)"
251
+ ])
252
+ }
253
+
254
+ @Test
255
+ func emitLog_multipleFatalsInBatch_eachTriggersFlush() {
256
+ let sink = RecordingSink()
257
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
258
+ // Two FATALs in a row — each gets its own flush. Wasteful but
259
+ // safer than batching: a flush after the first FATAL might
260
+ // succeed-then-die before reaching the second; flushing after
261
+ // each ensures both have a chance to reach disk independently.
262
+ d.emitBatch([
263
+ ["kind": "log", "name": "a", "severity": 21, "attributes": [:], "timeUnixNano": "1"],
264
+ ["kind": "log", "name": "b", "severity": 22, "attributes": [:], "timeUnixNano": "2"]
265
+ ])
266
+ #expect(sink.forceFlushes == 2)
267
+ }
268
+
269
+ @Test
270
+ func emitLog_fatalSeverityRangeBoundary() {
271
+ // OTel semconv: FATAL severity range is 21..24. Anything >= 21
272
+ // is FATAL. The dispatcher's threshold (>= 21) covers all.
273
+ let sink = RecordingSink()
274
+ let d = Dash0MobileBridgeDispatcher(sink: sink)
275
+ d.emitBatch([
276
+ ["kind": "log", "name": "fatal2", "severity": 22, "attributes": [:], "timeUnixNano": "1"],
277
+ ["kind": "log", "name": "fatal3", "severity": 23, "attributes": [:], "timeUnixNano": "2"],
278
+ ["kind": "log", "name": "fatal4", "severity": 24, "attributes": [:], "timeUnixNano": "3"],
279
+ ["kind": "log", "name": "warn", "severity": 13, "attributes": [:], "timeUnixNano": "4"]
280
+ ])
281
+ #expect(sink.forceFlushes == 3)
282
+ }
283
+ }
284
+
285
+ // ─── test double ──────────────────────────────────────────────────────────
286
+
287
+ private struct LogCall { let name: String; let severity: Int; let attributes: [String: Any]; let timeUnixNano: UInt64 }
288
+ private struct SpanStartCall { let spanId: String; let parentSpanId: String?; let name: String; let spanKind: String; let attributes: [String: Any]; let startTimeUnixNano: UInt64 }
289
+ private struct SpanEndCall { let spanId: String; let status: String; let statusMessage: String?; let attributes: [String: Any]; let endTimeUnixNano: UInt64 }
290
+ private struct MetricCall { let name: String; let instrumentType: String; let value: Double; let attributes: [String: Any]; let timeUnixNano: UInt64 }
291
+
292
+ private final class RecordingSink: BridgeCallSink {
293
+ var starts: [BridgeStartConfig] = []
294
+ var logs: [LogCall] = []
295
+ var spanStarts: [SpanStartCall] = []
296
+ var spanEnds: [SpanEndCall] = []
297
+ var metrics: [MetricCall] = []
298
+ var flushMinutes: [Int] = []
299
+ var shutdowns = 0
300
+ /// Records the order of `(action, payloadIndex)` so tests can assert
301
+ /// that `forceFlush` runs AFTER the FATAL emit but BEFORE the next
302
+ /// payload in the same batch. The dispatcher contract says: dispatch
303
+ /// the log first (so it lands in the buffer), then force-flush so
304
+ /// the buffer drains before the next payload (which might carry a
305
+ /// span end the FATAL log's trace context references).
306
+ var actionLog: [String] = []
307
+ var forceFlushes = 0
308
+
309
+ func start(_ config: BridgeStartConfig) {
310
+ starts.append(config)
311
+ actionLog.append("start")
312
+ }
313
+ func emitLog(name: String, severity: Int, attributes: [String: Any], timeUnixNano: UInt64) {
314
+ logs.append(LogCall(name: name, severity: severity, attributes: attributes, timeUnixNano: timeUnixNano))
315
+ actionLog.append("emitLog(\(name),\(severity))")
316
+ }
317
+ func startSpan(spanId: String, parentSpanId: String?, name: String, spanKind: String, attributes: [String: Any], startTimeUnixNano: UInt64) {
318
+ spanStarts.append(SpanStartCall(spanId: spanId, parentSpanId: parentSpanId, name: name, spanKind: spanKind, attributes: attributes, startTimeUnixNano: startTimeUnixNano))
319
+ actionLog.append("startSpan(\(name))")
320
+ }
321
+ func endSpan(spanId: String, status: String, statusMessage: String?, attributes: [String: Any], endTimeUnixNano: UInt64) {
322
+ spanEnds.append(SpanEndCall(spanId: spanId, status: status, statusMessage: statusMessage, attributes: attributes, endTimeUnixNano: endTimeUnixNano))
323
+ actionLog.append("endSpan(\(spanId))")
324
+ }
325
+ func recordMetric(name: String, instrumentType: String, value: Double, attributes: [String: Any], timeUnixNano: UInt64) {
326
+ metrics.append(MetricCall(name: name, instrumentType: instrumentType, value: value, attributes: attributes, timeUnixNano: timeUnixNano))
327
+ actionLog.append("recordMetric(\(name))")
328
+ }
329
+ func flushWindow(minutes: Int) {
330
+ flushMinutes.append(minutes)
331
+ actionLog.append("flushWindow(\(minutes))")
332
+ }
333
+ func shutdown() {
334
+ shutdowns += 1
335
+ actionLog.append("shutdown")
336
+ }
337
+ func forceFlush() {
338
+ forceFlushes += 1
339
+ actionLog.append("forceFlush")
340
+ }
341
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * TurboModule codegen spec for @dash0/mobile-react-native.
3
+ *
4
+ * This file is intentionally NOT imported by `src/index.ts` — it exists
5
+ * solely to drive RN codegen (0.68+ / Fabric). The runtime code path goes
6
+ * through `require('react-native').NativeModules.Dash0Mobile` so we keep
7
+ * working on both old-arch and new-arch.
8
+ *
9
+ * Codegen constraints (from React Native docs):
10
+ * - methods must return `Promise<T>` or `void`
11
+ * - parameters must be primitives, `Object`, `Array`, or `{ readonly ... }`
12
+ * - no union types as parameters (codegen rejects)
13
+ *
14
+ * The shapes below are deliberately looser than `bridge/types.ts` (Object
15
+ * instead of discriminated unions) because codegen can't narrow on `kind`.
16
+ * The native side runtime-dispatches on the `kind` string.
17
+ */
18
+ import type { TurboModule } from 'react-native';
19
+ export interface Spec extends TurboModule {
20
+ start(config: Object): Promise<void>;
21
+ emitBatch(payloads: Object[]): Promise<void>;
22
+ flushWindow(minutes: number): Promise<void>;
23
+ shutdown(): Promise<void>;
24
+ }
25
+ declare const _default: Spec;
26
+ export default _default;
27
+ //# sourceMappingURL=NativeDash0Mobile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NativeDash0Mobile.d.ts","sourceRoot":"","sources":["../../src/NativeDash0Mobile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;;AAED,wBAAqE"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * TurboModule codegen spec for @dash0/mobile-react-native.
3
+ *
4
+ * This file is intentionally NOT imported by `src/index.ts` — it exists
5
+ * solely to drive RN codegen (0.68+ / Fabric). The runtime code path goes
6
+ * through `require('react-native').NativeModules.Dash0Mobile` so we keep
7
+ * working on both old-arch and new-arch.
8
+ *
9
+ * Codegen constraints (from React Native docs):
10
+ * - methods must return `Promise<T>` or `void`
11
+ * - parameters must be primitives, `Object`, `Array`, or `{ readonly ... }`
12
+ * - no union types as parameters (codegen rejects)
13
+ *
14
+ * The shapes below are deliberately looser than `bridge/types.ts` (Object
15
+ * instead of discriminated unions) because codegen can't narrow on `kind`.
16
+ * The native side runtime-dispatches on the `kind` string.
17
+ */
18
+ import { TurboModuleRegistry } from 'react-native';
19
+ export default TurboModuleRegistry.getEnforcing('Dash0Mobile');
@@ -0,0 +1,38 @@
1
+ import type { BridgePayload, NativeDash0MobileModule } from './types';
2
+ export declare const DEBOUNCE_MS = 50;
3
+ export declare const MAX_QUEUE = 10000;
4
+ export declare class NativeBridge {
5
+ private readonly native;
6
+ private queue;
7
+ private timer;
8
+ private inFlight;
9
+ constructor(native: NativeDash0MobileModule);
10
+ emit(payload: BridgePayload): void;
11
+ /**
12
+ * Synchronous fast-path for FATAL-severity payloads. Cancels any pending
13
+ * debounce, drains the queue + the new payload in a single
14
+ * `native.emitBatch` call made SYNCHRONOUSLY on the current stack frame.
15
+ *
16
+ * Why not just `emit(); flush()`? Because `flush()` is `async`, and its
17
+ * `await this.drain()` places the call to `native.emitBatch` on the
18
+ * microtask queue. On a crash path (JS throw → ErrorUtils global handler
19
+ * → previous(error, isFatal) → RN fatal reporter → process exit) the
20
+ * handler's synchronous continuation wins the race against the
21
+ * microtask — the payload never crosses the bridge.
22
+ *
23
+ * This method is intentionally fire-and-forget: per the RN bridge
24
+ * contract, argument marshaling is synchronous, so the payload arrives
25
+ * on the native side by the time `native.emitBatch(...)` returns. The
26
+ * Promise resolution (native-side completion signal) is async and we
27
+ * don't await it — we only need the payload to cross the bridge before
28
+ * the process dies. `.catch(() => {})` prevents unhandledRejection
29
+ * warnings if the native side fails.
30
+ *
31
+ * See docs/superpowers/specs/2026-04-22-rn-fatal-bridge-bypass-design.md.
32
+ */
33
+ emitSync(payload: BridgePayload): void;
34
+ flush(): Promise<void>;
35
+ private drain;
36
+ private sendWithRetry;
37
+ }
38
+ //# sourceMappingURL=NativeBridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NativeBridge.d.ts","sourceRoot":"","sources":["../../../src/bridge/NativeBridge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAEtE,eAAO,MAAM,WAAW,KAAK,CAAC;AAC9B,eAAO,MAAM,SAAS,QAAS,CAAC;AAIhC,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;IACjD,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,QAAQ,CAA8B;gBAElC,MAAM,EAAE,uBAAuB;IAI3C,IAAI,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI;IAalC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI;IAgBhC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAQd,KAAK;YAYL,aAAa;CAY5B"}
@@ -0,0 +1,95 @@
1
+ export const DEBOUNCE_MS = 50;
2
+ export const MAX_QUEUE = 10000;
3
+ const RETRY_BASE_MS = 100;
4
+ const RETRY_MAX_ATTEMPTS = 5;
5
+ export class NativeBridge {
6
+ constructor(native) {
7
+ this.queue = [];
8
+ this.timer = null;
9
+ this.inFlight = null;
10
+ this.native = native;
11
+ }
12
+ emit(payload) {
13
+ this.queue.push(payload);
14
+ if (this.queue.length > MAX_QUEUE) {
15
+ this.queue.splice(0, this.queue.length - MAX_QUEUE);
16
+ }
17
+ if (this.timer === null) {
18
+ this.timer = setTimeout(() => {
19
+ this.timer = null;
20
+ void this.drain();
21
+ }, DEBOUNCE_MS);
22
+ }
23
+ }
24
+ /**
25
+ * Synchronous fast-path for FATAL-severity payloads. Cancels any pending
26
+ * debounce, drains the queue + the new payload in a single
27
+ * `native.emitBatch` call made SYNCHRONOUSLY on the current stack frame.
28
+ *
29
+ * Why not just `emit(); flush()`? Because `flush()` is `async`, and its
30
+ * `await this.drain()` places the call to `native.emitBatch` on the
31
+ * microtask queue. On a crash path (JS throw → ErrorUtils global handler
32
+ * → previous(error, isFatal) → RN fatal reporter → process exit) the
33
+ * handler's synchronous continuation wins the race against the
34
+ * microtask — the payload never crosses the bridge.
35
+ *
36
+ * This method is intentionally fire-and-forget: per the RN bridge
37
+ * contract, argument marshaling is synchronous, so the payload arrives
38
+ * on the native side by the time `native.emitBatch(...)` returns. The
39
+ * Promise resolution (native-side completion signal) is async and we
40
+ * don't await it — we only need the payload to cross the bridge before
41
+ * the process dies. `.catch(() => {})` prevents unhandledRejection
42
+ * warnings if the native side fails.
43
+ *
44
+ * See docs/superpowers/specs/2026-04-22-rn-fatal-bridge-bypass-design.md.
45
+ */
46
+ emitSync(payload) {
47
+ if (this.timer !== null) {
48
+ clearTimeout(this.timer);
49
+ this.timer = null;
50
+ }
51
+ const batch = this.queue.length > 0 ? [...this.queue, payload] : [payload];
52
+ this.queue = [];
53
+ this.native.emitBatch(batch).catch(() => {
54
+ // Swallow — on the crash path, the process is dying anyway and
55
+ // there's no caller alive to observe the error. On non-crash paths
56
+ // (if any caller uses emitSync for non-FATAL severity) the payload
57
+ // is lost on failure, which matches the implicit "best-effort"
58
+ // contract of the RN bridge under adverse conditions.
59
+ });
60
+ }
61
+ async flush() {
62
+ if (this.timer !== null) {
63
+ clearTimeout(this.timer);
64
+ this.timer = null;
65
+ }
66
+ await this.drain();
67
+ }
68
+ async drain() {
69
+ if (this.queue.length === 0)
70
+ return;
71
+ const batch = this.queue;
72
+ this.queue = [];
73
+ this.inFlight = this.sendWithRetry(batch);
74
+ try {
75
+ await this.inFlight;
76
+ }
77
+ finally {
78
+ this.inFlight = null;
79
+ }
80
+ }
81
+ async sendWithRetry(batch) {
82
+ for (let attempt = 0; attempt < RETRY_MAX_ATTEMPTS; attempt++) {
83
+ try {
84
+ await this.native.emitBatch(batch);
85
+ return;
86
+ }
87
+ catch {
88
+ if (attempt === RETRY_MAX_ATTEMPTS - 1)
89
+ return;
90
+ const delay = RETRY_BASE_MS * Math.pow(2, attempt);
91
+ await new Promise(resolve => setTimeout(resolve, delay));
92
+ }
93
+ }
94
+ }
95
+ }