@scalebun/react-native 1.4.0 → 1.6.0

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 (50) hide show
  1. package/README.md +33 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/BsPatch.kt +182 -0
  3. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +88 -13
  4. package/android/src/oldarch/java/com/scalebun/rn/crash/ScaleBunCrashSpec.kt +16 -0
  5. package/bin/lib/androidCodemod.js +370 -0
  6. package/bin/scalebun.js +305 -13
  7. package/dist/scalebun.full.js +129 -97
  8. package/dist/scalebun.full.js.map +1 -1
  9. package/dist/scalebun.slim.js +129 -97
  10. package/dist/scalebun.slim.js.map +1 -1
  11. package/ios/Ota/BsPatch.swift +180 -0
  12. package/ios/Ota/OtaSlotManager.swift +59 -2
  13. package/ios/Ota/ScaleBunOtaModule.swift +89 -10
  14. package/lib/commonjs/analytics/EventTracker.js +21 -1
  15. package/lib/commonjs/analytics/EventTracker.js.map +1 -1
  16. package/lib/commonjs/core/constants/version.js +1 -1
  17. package/lib/commonjs/core/id/installationId.js +55 -0
  18. package/lib/commonjs/core/id/installationId.js.map +1 -0
  19. package/lib/commonjs/debug/bootstrap.js +12 -0
  20. package/lib/commonjs/debug/bootstrap.js.map +1 -1
  21. package/lib/commonjs/features/ota/OtaOrchestrator.js +2 -30
  22. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  23. package/lib/commonjs/features/session/BackendSessionAdapter.js +8 -0
  24. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  25. package/lib/module/analytics/EventTracker.js +21 -1
  26. package/lib/module/analytics/EventTracker.js.map +1 -1
  27. package/lib/module/core/constants/version.js +1 -1
  28. package/lib/module/core/id/installationId.js +50 -0
  29. package/lib/module/core/id/installationId.js.map +1 -0
  30. package/lib/module/debug/bootstrap.js +12 -0
  31. package/lib/module/debug/bootstrap.js.map +1 -1
  32. package/lib/module/features/ota/OtaOrchestrator.js +1 -29
  33. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  34. package/lib/module/features/session/BackendSessionAdapter.js +8 -0
  35. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  36. package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
  37. package/lib/typescript/core/constants/version.d.ts +1 -1
  38. package/lib/typescript/core/id/installationId.d.ts +20 -0
  39. package/lib/typescript/core/id/installationId.d.ts.map +1 -0
  40. package/lib/typescript/debug/bootstrap.d.ts +18 -0
  41. package/lib/typescript/debug/bootstrap.d.ts.map +1 -1
  42. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  43. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  44. package/package.json +3 -2
  45. package/src/analytics/EventTracker.ts +21 -1
  46. package/src/core/constants/version.ts +1 -1
  47. package/src/core/id/installationId.ts +52 -0
  48. package/src/debug/bootstrap.ts +36 -0
  49. package/src/features/ota/OtaOrchestrator.ts +1 -30
  50. package/src/features/session/BackendSessionAdapter.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalebun/react-native",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Production-grade React Native SDK for ScaleBun",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -104,7 +104,8 @@
104
104
  "dependencies": {
105
105
  "@babel/runtime": "^7.25.0",
106
106
  "@jridgewell/sourcemap-codec": "1.5.5",
107
- "@jridgewell/trace-mapping": "0.3.31"
107
+ "@jridgewell/trace-mapping": "0.3.31",
108
+ "@scalebun/cli": "^1.6.0"
108
109
  },
109
110
  "codegenConfig": {
110
111
  "name": "ScaleBunSpec",
@@ -317,7 +317,27 @@ export class EventTracker {
317
317
  const effectiveBatch = clampBatchSize(this.cfg.batchSize, DEFAULT_BATCH);
318
318
  const batch = this.queue.slice(0, effectiveBatch);
319
319
  try {
320
- // Sign the exact bytes we send so the backend can tamper-detect the payload.
320
+ // WHAT THIS SIGNATURE IS, AND WHAT IT IS NOT.
321
+ //
322
+ // This used to be described as letting "the backend tamper-detect
323
+ // the payload", which overstates it to the point of being wrong.
324
+ // The HMAC key is `clientKey` — the same value sent beside it in
325
+ // `x-scalebun-client-key`, and a value that ships inside every
326
+ // installed app, extractable by anyone who unzips an APK. A MAC
327
+ // whose key travels with the message and is public by construction
328
+ // proves nothing about who sent it: anyone can compute a valid one.
329
+ //
330
+ // What it DOES buy is integrity in transit against non-adversarial
331
+ // corruption, and a cheap filter for junk traffic. Treat `clientKey`
332
+ // as a publishable identifier (like a Stripe publishable key), never
333
+ // as a trust boundary. Authenticity that actually holds has to come
334
+ // from something the client cannot forge — server-side rate limits
335
+ // and anomaly detection per key, short-lived device tokens, or
336
+ // platform attestation (Play Integrity / App Attest).
337
+ //
338
+ // Contrast with OTA bundle signing (features/ota/signature.ts),
339
+ // which is asymmetric: the private half never leaves the publisher,
340
+ // so a valid signature there IS proof of origin.
321
341
  const payload = JSON.stringify({ events: batch });
322
342
  // Wave 2 (§2F.2): a stable idempotency key over the batch's event ids so a
323
343
  // retry after a timeout/disconnect replays the first response instead of
@@ -3,4 +3,4 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export const SDK_VERSION = '1.4.0';
6
+ export const SDK_VERSION = '1.6.0';
@@ -0,0 +1,52 @@
1
+ import { createStorageBackend } from '../../storage/StorageBackend';
2
+ import { K_INSTALLATION_ID } from '../../analytics/EventTracker';
3
+
4
+ /**
5
+ * The SDK's canonical per-install id.
6
+ *
7
+ * ONE install, ONE id, every lane. This is deliberately NOT `getDeviceId()` —
8
+ * that is a separate value under a separate storage key, and the backend schema
9
+ * annotates the two as different ("stable per-install id from the SDK (≠
10
+ * deviceId)").
11
+ *
12
+ * It matters because the backend joins a bundle's OTA installs to that device's
13
+ * sessions through this value. OTA events have always been keyed by it; the
14
+ * session lane did not send it at all, so on the server the two could only be
15
+ * connected for devices that happened to have registered for push. Release
16
+ * health then reported "no data" for bundles that were running perfectly well —
17
+ * or crashing badly.
18
+ *
19
+ * Lives here rather than inside one feature so the OTA orchestrator and the
20
+ * session adapter cannot drift to two different ids.
21
+ */
22
+ export function resolveInstallationId(): string {
23
+ try {
24
+ const storage = createStorageBackend();
25
+ const existing = storage.get(K_INSTALLATION_ID);
26
+ if (existing) return existing;
27
+ const id = `inst-${randomSegment()}${randomSegment()}`;
28
+ storage.set(K_INSTALLATION_ID, id);
29
+ return id;
30
+ } catch {
31
+ // Storage unavailable (tests, exotic hosts): a per-process id keeps the
32
+ // lanes functional; it stabilizes on the first run where storage works.
33
+ return `inst-ephemeral-${randomSegment()}`;
34
+ }
35
+ }
36
+
37
+ /** 8 chars of base36 entropy, crypto-backed when the runtime offers it. */
38
+ function randomSegment(): string {
39
+ try {
40
+ const g = globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } };
41
+ if (g.crypto?.getRandomValues) {
42
+ const bytes = g.crypto.getRandomValues(new Uint8Array(6));
43
+ return Array.from(bytes)
44
+ .map((b) => b.toString(36).padStart(2, '0'))
45
+ .join('')
46
+ .slice(0, 8);
47
+ }
48
+ } catch {
49
+ // fall through to Math.random
50
+ }
51
+ return Math.random().toString(36).slice(2, 10).padEnd(8, '0');
52
+ }
@@ -67,6 +67,24 @@ export interface DebugConfig {
67
67
  /** @internal */
68
68
  _performanceDisabled?: boolean;
69
69
  enableProfiler?: boolean;
70
+ /**
71
+ * Permit the debug channel in a RELEASE build. Default false, and you
72
+ * almost certainly want it that way.
73
+ *
74
+ * The transport is an unencrypted `ws://` socket that carries captured
75
+ * console output, network traffic and the shared `secret` itself in
76
+ * cleartext, readable by anyone on the same Wi-Fi. That is an acceptable
77
+ * trade for a developer on their own machine and not acceptable in an app
78
+ * on a user's phone.
79
+ *
80
+ * Until now the only thing standing between those two cases was that the
81
+ * desktop tooling is excluded from bundles by default — a BUILD-time
82
+ * opt-out, not a release check. Anyone who set `devTools: true` to debug
83
+ * locally and shipped without reverting it published an app that opens
84
+ * that socket in production. This flag makes that a deliberate act rather
85
+ * than an oversight.
86
+ */
87
+ allowInRelease?: boolean;
70
88
  }
71
89
 
72
90
  // ─── Singleton State ────────────────────────────────────────────────────────
@@ -115,6 +133,24 @@ export function enableDebug(config: DebugConfig): void {
115
133
  return;
116
134
  }
117
135
 
136
+ // Refuse in release builds. See `allowInRelease` on DebugConfig: this
137
+ // channel is plaintext ws:// carrying console output, network traffic
138
+ // and the shared secret, and the only previous guard was that the
139
+ // tooling is excluded from bundles by default — which a developer
140
+ // disables to debug locally and then forgets. Warn rather than fail
141
+ // silently, because a debug connection that does nothing with no
142
+ // explanation is its own long afternoon.
143
+ if (!__DEV__ && !config.allowInRelease) {
144
+ logger.warn(
145
+ '[ScaleBun] Debug connection refused in a release build. The debug ' +
146
+ 'transport is unencrypted (ws://) and carries captured traffic and the ' +
147
+ 'shared secret in cleartext, so it is development-only by default. If ' +
148
+ 'you genuinely need it in a release build, set ' +
149
+ '`allowInRelease: true` — and do not ship that to users.',
150
+ );
151
+ return;
152
+ }
153
+
118
154
  // The desktop-debugger tooling can be excluded from a build via
119
155
  // `withScaleBun(config, { features: { devTools: false } })`, which resolves these modules
120
156
  // to an empty stub. Detect that explicitly: without this check the first `new
@@ -13,7 +13,7 @@ import type { OtaCheckRequest, OtaCheckResponse, OtaBundlePayload } from './OtaT
13
13
  import { otaEventEmitter } from './OtaEventEmitter';
14
14
  import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
15
15
  import { deviceTargetingAttributes } from './deviceAttributes';
16
- import { K_INSTALLATION_ID } from '../../analytics/EventTracker';
16
+ import { resolveInstallationId } from '../../core/id/installationId';
17
17
  import { verifyBundleSignature, type SignatureConfig } from './signature';
18
18
  import { detectOtaEnvironment, type OtaEnvironment } from './environment';
19
19
  import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
@@ -71,35 +71,6 @@ function subscribeNativeProgress(
71
71
  /** POST at most once per this interval; first and terminal ticks always go. */
72
72
  const PROGRESS_POST_INTERVAL_MS = 1_000;
73
73
 
74
- /**
75
- * P8 — the SDK's canonical per-install id, shared with the analytics lane.
76
- *
77
- * OTA used to REQUIRE a caller-supplied `installationId`, and the documented
78
- * example passed `deviceId` — so OTA telemetry lived in its own identity
79
- * namespace, disconnected from the id every other lane reports. Every join
80
- * from OTA data to CDP data (blast radius, MRR-at-risk, churn bands) was a
81
- * cross-namespace join that matched by luck.
82
- *
83
- * Reads (or mints, for OTA-only apps that never initialize analytics) the same
84
- * persisted value `EventTracker` uses, via the same storage backend and the
85
- * same exported key — one install, one id, every lane.
86
- */
87
- function resolveInstallationId(): string {
88
- try {
89
- const storage = createStorageBackend();
90
- const existing = storage.get(K_INSTALLATION_ID);
91
- if (existing) return existing;
92
- const id = `inst-${Math.random().toString(36).slice(2, 10)}${Math.random()
93
- .toString(36)
94
- .slice(2, 10)}`;
95
- storage.set(K_INSTALLATION_ID, id);
96
- return id;
97
- } catch {
98
- // Storage unavailable (tests, exotic hosts): a per-process id keeps sync
99
- // functional; it will stabilize on the first run where storage works.
100
- return `inst-ephemeral-${Math.random().toString(36).slice(2, 10)}`;
101
- }
102
- }
103
74
 
104
75
  /**
105
76
  * Deliver queued OTA lifecycle events to the backend's batch endpoint.
@@ -26,6 +26,7 @@
26
26
  import { logger } from '../../core/logger/internalLogger';
27
27
  import { base64ToBytes } from '../../core/encoding/base64';
28
28
  import { ENDPOINTS } from '../../transport/http/endpoints';
29
+ import { resolveInstallationId } from '../../core/id/installationId';
29
30
  import { SDK_VERSION } from '../../core/constants/version';
30
31
  import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
31
32
  import { getReplaySdkNative } from '../replay/bridge/nativeModule';
@@ -262,6 +263,13 @@ export class BackendSessionAdapter implements SessionTransport {
262
263
  const payload = {
263
264
  sessionId: session.sessionId,
264
265
  deviceId: session.deviceId,
266
+ // The canonical per-install id — the SAME value OTA events are
267
+ // reported under, and a DIFFERENT value from deviceId. The
268
+ // backend stores it on the Device so release health can join a
269
+ // bundle's installs to that device's sessions. Without it, crash
270
+ // impact for a release resolves to "no data" for every device
271
+ // that never registered for push.
272
+ installationId: resolveInstallationId(),
265
273
  startedAt: session.startedAt,
266
274
  // Link the recording row to the always-on analytics row so the
267
275
  // dashboard can join them. track() events land on the analytics