@scalebun/react-native 1.0.1 → 1.0.3
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.
- package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +170 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +160 -12
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +32 -6
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +7 -1
- package/ios/Ota/BundleDownloader.swift +182 -0
- package/ios/Ota/OtaSlotManager.swift +35 -9
- package/ios/Ota/ScaleBunOtaBridge.mm +9 -0
- package/ios/Ota/ScaleBunOtaEventsModule.swift +61 -0
- package/ios/Ota/ScaleBunOtaModule.swift +58 -5
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +22 -0
- package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
- package/lib/commonjs/features/crash/CrashFeature.js +18 -0
- package/lib/commonjs/features/crash/CrashFeature.js.map +1 -1
- package/lib/commonjs/features/crash/rejectionHandler.js +123 -0
- package/lib/commonjs/features/crash/rejectionHandler.js.map +1 -0
- package/lib/commonjs/features/engage/EngagePromptProvider.js +4 -2
- package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
- package/lib/commonjs/features/engage/engageThrottle.js +41 -10
- package/lib/commonjs/features/engage/engageThrottle.js.map +1 -1
- package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +53 -10
- package/lib/commonjs/features/navigation/AutoScreenDetector.js.map +1 -1
- package/lib/commonjs/features/ota/OtaOrchestrator.js +515 -20
- package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
- package/lib/commonjs/features/ota/environment.js +172 -0
- package/lib/commonjs/features/ota/environment.js.map +1 -0
- package/lib/commonjs/features/ota/retry.js +96 -0
- package/lib/commonjs/features/ota/retry.js.map +1 -0
- package/lib/commonjs/features/ota/signature.js +115 -0
- package/lib/commonjs/features/ota/signature.js.map +1 -0
- package/lib/commonjs/features/session/BackendSessionAdapter.js +231 -26
- package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/public/ScaleBunErrorBoundary.js +79 -0
- package/lib/commonjs/public/ScaleBunErrorBoundary.js.map +1 -0
- package/lib/commonjs/specs/NativeScaleBunOta.js.map +1 -1
- package/lib/module/bootstrap/SDKBootstrapper.js +22 -0
- package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
- package/lib/module/features/crash/CrashFeature.js +18 -0
- package/lib/module/features/crash/CrashFeature.js.map +1 -1
- package/lib/module/features/crash/rejectionHandler.js +116 -0
- package/lib/module/features/crash/rejectionHandler.js.map +1 -0
- package/lib/module/features/engage/EngagePromptProvider.js +4 -2
- package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
- package/lib/module/features/engage/engageThrottle.js +41 -10
- package/lib/module/features/engage/engageThrottle.js.map +1 -1
- package/lib/module/features/engage/engageTypes.js.map +1 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +53 -10
- package/lib/module/features/navigation/AutoScreenDetector.js.map +1 -1
- package/lib/module/features/ota/OtaOrchestrator.js +516 -21
- package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
- package/lib/module/features/ota/environment.js +165 -0
- package/lib/module/features/ota/environment.js.map +1 -0
- package/lib/module/features/ota/retry.js +87 -0
- package/lib/module/features/ota/retry.js.map +1 -0
- package/lib/module/features/ota/signature.js +109 -0
- package/lib/module/features/ota/signature.js.map +1 -0
- package/lib/module/features/session/BackendSessionAdapter.js +230 -26
- package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/public/ScaleBunErrorBoundary.js +70 -0
- package/lib/module/public/ScaleBunErrorBoundary.js.map +1 -0
- package/lib/module/specs/NativeScaleBunOta.js.map +1 -1
- package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
- package/lib/typescript/features/crash/CrashFeature.d.ts.map +1 -1
- package/lib/typescript/features/crash/rejectionHandler.d.ts +46 -0
- package/lib/typescript/features/crash/rejectionHandler.d.ts.map +1 -0
- package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
- package/lib/typescript/features/engage/engageThrottle.d.ts +24 -4
- package/lib/typescript/features/engage/engageThrottle.d.ts.map +1 -1
- package/lib/typescript/features/engage/engageTypes.d.ts +6 -0
- package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +9 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts.map +1 -1
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +67 -1
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
- package/lib/typescript/features/ota/OtaTypes.d.ts +42 -0
- package/lib/typescript/features/ota/OtaTypes.d.ts.map +1 -1
- package/lib/typescript/features/ota/environment.d.ts +99 -0
- package/lib/typescript/features/ota/environment.d.ts.map +1 -0
- package/lib/typescript/features/ota/retry.d.ts +53 -0
- package/lib/typescript/features/ota/retry.d.ts.map +1 -0
- package/lib/typescript/features/ota/signature.d.ts +66 -0
- package/lib/typescript/features/ota/signature.d.ts.map +1 -0
- package/lib/typescript/features/session/BackendSessionAdapter.d.ts +93 -2
- package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +6 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/public/ScaleBunErrorBoundary.d.ts +44 -0
- package/lib/typescript/public/ScaleBunErrorBoundary.d.ts.map +1 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +11 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/bootstrap/SDKBootstrapper.ts +24 -0
- package/src/features/crash/CrashFeature.ts +19 -0
- package/src/features/crash/rejectionHandler.ts +134 -0
- package/src/features/engage/EngagePromptProvider.tsx +4 -2
- package/src/features/engage/engageThrottle.ts +44 -6
- package/src/features/engage/engageTypes.ts +6 -0
- package/src/features/navigation/AutoScreenDetector.ts +59 -4
- package/src/features/ota/OtaOrchestrator.ts +594 -23
- package/src/features/ota/OtaTypes.ts +56 -0
- package/src/features/ota/environment.ts +199 -0
- package/src/features/ota/retry.ts +123 -0
- package/src/features/ota/signature.ts +123 -0
- package/src/features/session/BackendSessionAdapter.ts +233 -25
- package/src/index.ts +6 -0
- package/src/public/ScaleBunErrorBoundary.tsx +79 -0
- package/src/specs/NativeScaleBunOta.ts +12 -0
|
@@ -31,6 +31,10 @@ import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
|
|
|
31
31
|
import { getReplaySdkNative } from '../replay/bridge/nativeModule';
|
|
32
32
|
import type { ReplaySdkNativeModule } from '../replay/bridge/nativeModule';
|
|
33
33
|
import type { StorageBackend } from '../../storage/StorageBackend';
|
|
34
|
+
// Reused rather than reimplemented: `chunkEvents` is generic, order-preserving, and already carries
|
|
35
|
+
// a contract test proving it drops and duplicates nothing (analytics/__tests__/batchChunking.test.ts).
|
|
36
|
+
// Its internal ceiling (MAX_BATCH_EVENTS = 500) is well above the 100-frame cap passed here.
|
|
37
|
+
import { chunkEvents } from '../../analytics/batching';
|
|
34
38
|
import type {
|
|
35
39
|
SessionTransport,
|
|
36
40
|
Session,
|
|
@@ -110,8 +114,31 @@ export class BackendSessionAdapter implements SessionTransport {
|
|
|
110
114
|
private readonly _frameStore: StorageBackend | null;
|
|
111
115
|
/** Storage key for the snapshotted unflushed frame buffer (see persistPendingFrames). */
|
|
112
116
|
private static readonly PENDING_FRAMES_KEY = 'scalebun_replay_pending_frames';
|
|
113
|
-
/**
|
|
114
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Byte budget for the cross-kill frame snapshot (see `_selectFramesToPersist`).
|
|
119
|
+
*
|
|
120
|
+
* Replaces a flat 10-frame cap. A count could not bound the KV write at all — these frames carry
|
|
121
|
+
* base64 screenshots, so ten of them is anywhere from ~200 KB to several MB depending on device
|
|
122
|
+
* resolution, and an oversized value is exactly what fails on the background transition where
|
|
123
|
+
* this write happens.
|
|
124
|
+
*
|
|
125
|
+
* 2 MB is deliberately modest: this is a last-resort snapshot for frames that never reached the
|
|
126
|
+
* network, not a mirror of the session. Enough for a keyframe plus a run of deltas on any device;
|
|
127
|
+
* small enough that writing it cannot itself be the reason backgrounding stalls.
|
|
128
|
+
*/
|
|
129
|
+
private static readonly MAX_PERSIST_BYTES = 2 * 1024 * 1024;
|
|
130
|
+
/**
|
|
131
|
+
* Frames the backend accepts in ONE request — `'Max 100 frames per batch'`
|
|
132
|
+
* (`IngestionService.uploadFrames`). Exceeding it is a 4xx, which this SDK classifies as
|
|
133
|
+
* permanent.
|
|
134
|
+
*
|
|
135
|
+
* This has to exist alongside the byte budget, not instead of it. The budget alone cannot bound
|
|
136
|
+
* the COUNT, and the count is what the server checks: measured on real traffic, mobile keyframes
|
|
137
|
+
* average ~16 KB, so a 2 MB snapshot is around 128 frames — over the cap by construction. The old
|
|
138
|
+
* flat 10-frame rule was under it by construction, which is why this failure could not happen
|
|
139
|
+
* before and can now.
|
|
140
|
+
*/
|
|
141
|
+
private static readonly MAX_FRAMES_PER_BATCH = 100;
|
|
115
142
|
// Per-session toggle: cleared to false if the backend reports storage disabled.
|
|
116
143
|
private objectStorageActive: boolean;
|
|
117
144
|
// True when telemetry should divert to the durable native outbox (both flags on).
|
|
@@ -139,6 +166,14 @@ export class BackendSessionAdapter implements SessionTransport {
|
|
|
139
166
|
private pendingLogs: LogQueueItem[] = [];
|
|
140
167
|
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
141
168
|
private destroyed = false;
|
|
169
|
+
/**
|
|
170
|
+
* Latch for `sdk_replay_capture_failed` — reported once per adapter, not once per rejected batch.
|
|
171
|
+
*
|
|
172
|
+
* A session whose row was never created rejects EVERY frame batch, so an unlatched report would
|
|
173
|
+
* emit one analytics event per flush for as long as the app stays open, on the same lane it is
|
|
174
|
+
* trying to report through.
|
|
175
|
+
*/
|
|
176
|
+
private _captureFailureReported = false;
|
|
142
177
|
|
|
143
178
|
// ── Analytics lane (always-on, foreground-scoped; isolated from recording) ──
|
|
144
179
|
// Fully separate from currentSessionId/pendingEvents/_flushEvents, which serve
|
|
@@ -344,6 +379,11 @@ export class BackendSessionAdapter implements SessionTransport {
|
|
|
344
379
|
}): Promise<void> {
|
|
345
380
|
if (this.destroyed) return;
|
|
346
381
|
this._analyticsSessionId = session.sessionId;
|
|
382
|
+
// A new session gets its own capture-failure report. The latch bounds reports WITHIN a
|
|
383
|
+
// session; left standing across sessions it silenced every session after the first in the
|
|
384
|
+
// app process, which is the one place this differs from the web SDK — that pipeline is
|
|
385
|
+
// constructed per page and its session id is readonly, so its latch cannot outlive a session.
|
|
386
|
+
this._captureFailureReported = false;
|
|
347
387
|
this._startAnalyticsFlushTimer();
|
|
348
388
|
|
|
349
389
|
// Replay pre-ready buffered events now that the id exists. sessionId is
|
|
@@ -838,21 +878,109 @@ export class BackendSessionAdapter implements SessionTransport {
|
|
|
838
878
|
const sessionId = this.currentSessionId;
|
|
839
879
|
|
|
840
880
|
const batch = this.pendingFrames.splice(0, this.config.batchSize);
|
|
881
|
+
/**
|
|
882
|
+
* Chunked, because `config.batchSize` is HOST-CONFIGURABLE and the server's 100-frame cap is
|
|
883
|
+
* not. A host raising it past 100 previously turned every frame flush into a permanent 4xx —
|
|
884
|
+
* silent, total replay loss for that app, from a config value that looks like a tuning knob.
|
|
885
|
+
*
|
|
886
|
+
* A permanent rejection is reported and abandoned inside `_postFramesChunked`; what comes
|
|
887
|
+
* back is only what is still worth another attempt.
|
|
888
|
+
*/
|
|
889
|
+
const remaining = await this._postFramesChunked(sessionId, batch);
|
|
890
|
+
if (remaining.length) {
|
|
891
|
+
logger.warn(`[replay.sdk.error] backend frame upload failure`, { count: remaining.length });
|
|
892
|
+
this.pendingFrames.unshift(...remaining);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
logger.debug(`[BackendSessionAdapter] Flushed ${batch.length} frame(s)`);
|
|
896
|
+
// Delivered — the durable snapshot (if any) is now redundant. Clear it once
|
|
897
|
+
// the in-memory buffer is fully drained so a later kill can't replay
|
|
898
|
+
// already-uploaded frames.
|
|
899
|
+
if (this.pendingFrames.length === 0) this._clearPersistedFrames();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Report frames the SDK gave up on, on the analytics lane.
|
|
904
|
+
*
|
|
905
|
+
* SAME EVENT NAME AS THE WEB SDK (`sdk_replay_capture_failed`) with platform-specific `reason`
|
|
906
|
+
* values, so one dashboard breakdown reads both platforms without a concept-map alias — the two
|
|
907
|
+
* SDKs share no event vocabulary otherwise (`$pageview` vs `screen_viewed`), and adding a second
|
|
908
|
+
* name here would need an entry in `event-concepts.ts` for no benefit.
|
|
909
|
+
*
|
|
910
|
+
* Latched to once per session: a session whose row was never created rejects every batch, and an
|
|
911
|
+
* unlatched report would emit one event per flush for as long as the app is open.
|
|
912
|
+
*
|
|
913
|
+
* Best-effort and never throws — a diagnostic must not be able to break the capture path it
|
|
914
|
+
* describes.
|
|
915
|
+
*/
|
|
916
|
+
private _reportCaptureFailure(stage: string, err: unknown, frameCount: number): void {
|
|
917
|
+
if (this._captureFailureReported) return;
|
|
918
|
+
this._captureFailureReported = true;
|
|
841
919
|
try {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
920
|
+
if (!this._analyticsSessionId) return; // no lane open — nothing to attach it to
|
|
921
|
+
const status = (err as { status?: number })?.status;
|
|
922
|
+
this.trackEvent({
|
|
923
|
+
eventId: this._clientId(),
|
|
924
|
+
sessionId: this._analyticsSessionId,
|
|
925
|
+
ts: Date.now(),
|
|
926
|
+
type: 'sdk_replay_capture_failed',
|
|
927
|
+
source: 'sdk',
|
|
928
|
+
payload: {
|
|
929
|
+
stage,
|
|
930
|
+
// Platform-specific vocabulary. The HTTP status is the honest reason on this path:
|
|
931
|
+
// RN posts to the per-lane endpoints, which surface a real status — unlike the web
|
|
932
|
+
// batch fan-out, whose 200 says nothing about what it stored.
|
|
933
|
+
reason: status ? `http_${status}` : 'upload_failed',
|
|
934
|
+
frames: frameCount,
|
|
935
|
+
platform: 'react-native',
|
|
936
|
+
},
|
|
937
|
+
} as unknown as JourneyEvent);
|
|
938
|
+
} catch {
|
|
939
|
+
// Diagnostics must never break the capture path.
|
|
853
940
|
}
|
|
854
941
|
}
|
|
855
942
|
|
|
943
|
+
/**
|
|
944
|
+
* Post frames in BACKEND-SAFE CHUNKS, and return the ones that were not durably accepted.
|
|
945
|
+
*
|
|
946
|
+
* WHY THIS EXISTS. `_postFrameBatch` issues exactly one request for whatever array it is handed,
|
|
947
|
+
* and both callers could hand it more than the server's 100-frame cap — the live flush via
|
|
948
|
+
* `config.batchSize`, and cross-kill recovery via the 2 MB persistence budget (~128 frames at the
|
|
949
|
+
* measured 16 KB average). The result was a 4xx, classified permanent, and on the recovery path
|
|
950
|
+
* that meant `_clearPersistedFrames()` — the ENTIRE cross-kill snapshot deleted because it was
|
|
951
|
+
* one frame too big to send in a single request it never needed to be sent in.
|
|
952
|
+
*
|
|
953
|
+
* THE RETURN VALUE IS THE POINT. The caller must be able to delete exactly what landed and keep
|
|
954
|
+
* exactly what did not, so this reports the survivors rather than throwing:
|
|
955
|
+
* - chunk accepted → dropped from the result (it is durably stored)
|
|
956
|
+
* - chunk PERMANENTLY refused → dropped, and reported once. Re-sending identical bytes cannot
|
|
957
|
+
* succeed, and holding it would wedge every future launch on the
|
|
958
|
+
* same poison chunk. Only that chunk is abandoned; the rest still
|
|
959
|
+
* gets its chance, which is the whole difference from before.
|
|
960
|
+
* - chunk TRANSIENTLY failed → that chunk AND every chunk after it are kept, in order. We stop
|
|
961
|
+
* on the first transient failure rather than pressing on, because
|
|
962
|
+
* the usual cause is the network being gone and the rest would
|
|
963
|
+
* fail identically.
|
|
964
|
+
*/
|
|
965
|
+
private async _postFramesChunked(
|
|
966
|
+
sessionId: string,
|
|
967
|
+
items: Array<{ frame: ReplayFrame; imageData: string }>,
|
|
968
|
+
): Promise<Array<{ frame: ReplayFrame; imageData: string }>> {
|
|
969
|
+
const chunks = chunkEvents(items, BackendSessionAdapter.MAX_FRAMES_PER_BATCH);
|
|
970
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
971
|
+
try {
|
|
972
|
+
await this._postFrameBatch(sessionId, chunks[i]);
|
|
973
|
+
} catch (err) {
|
|
974
|
+
if (BackendSessionAdapter._isPermanentHttpError(err)) {
|
|
975
|
+
this._reportCaptureFailure('upload-rejected', err, chunks[i].length);
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
return chunks.slice(i).flat();
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return [];
|
|
982
|
+
}
|
|
983
|
+
|
|
856
984
|
/**
|
|
857
985
|
* POST one frame batch under an EXPLICIT sessionId (object-storage path when
|
|
858
986
|
* active, else legacy inline base64). Used by both the live flush and cross-kill
|
|
@@ -905,7 +1033,8 @@ export class BackendSessionAdapter implements SessionTransport {
|
|
|
905
1033
|
if (!this._frameStore || this.destroyed || !this.currentSessionId) return;
|
|
906
1034
|
if (this.pendingFrames.length === 0) return;
|
|
907
1035
|
try {
|
|
908
|
-
const items = this.pendingFrames
|
|
1036
|
+
const items = BackendSessionAdapter._selectFramesToPersist(this.pendingFrames);
|
|
1037
|
+
if (items.length === 0) return;
|
|
909
1038
|
this._frameStore.set(
|
|
910
1039
|
BackendSessionAdapter.PENDING_FRAMES_KEY,
|
|
911
1040
|
JSON.stringify({ sessionId: this.currentSessionId, items }),
|
|
@@ -915,6 +1044,70 @@ export class BackendSessionAdapter implements SessionTransport {
|
|
|
915
1044
|
}
|
|
916
1045
|
}
|
|
917
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* Choose which unflushed frames survive a process kill.
|
|
1049
|
+
*
|
|
1050
|
+
* WHY THIS IS NOT `slice(-10)`.
|
|
1051
|
+
*
|
|
1052
|
+
* The old rule kept the ten most RECENT frames, which is the wrong end of the buffer for replay.
|
|
1053
|
+
* A delta reconstructs against its base keyframe, so ten trailing deltas whose keyframe was
|
|
1054
|
+
* dropped are unplayable on their own — they arrive as `orphaned`, the state the dashboard flags
|
|
1055
|
+
* as a capture defect. Keeping the keyframe and fewer deltas yields a SHORTER replay; keeping the
|
|
1056
|
+
* deltas and no keyframe yields NO replay. That asymmetry is the whole decision.
|
|
1057
|
+
*
|
|
1058
|
+
* So: every keyframe is kept, then the most recent deltas fill whatever byte budget is left. This
|
|
1059
|
+
* mirrors the web SDK's `capBytes` policy (which sheds deltas first and never the keyframe), so
|
|
1060
|
+
* the two platforms lose the same thing under pressure instead of two different things.
|
|
1061
|
+
*
|
|
1062
|
+
* BYTES, not a frame count. A count is a poor proxy here: these carry base64 screenshots, so ten
|
|
1063
|
+
* frames may be 200 KB or 4 MB depending on device resolution — and this write goes to a KV store
|
|
1064
|
+
* on the background transition, where an oversized value is the thing most likely to fail and
|
|
1065
|
+
* lose the snapshot entirely. `imageData.length` is the base64 length, which is within ~1% of the
|
|
1066
|
+
* serialized cost and needs no extra pass.
|
|
1067
|
+
*/
|
|
1068
|
+
private static _selectFramesToPersist(
|
|
1069
|
+
pending: Array<{ frame: ReplayFrame; imageData: string }>,
|
|
1070
|
+
): Array<{ frame: ReplayFrame; imageData: string }> {
|
|
1071
|
+
const isKeyframe = (b: { frame: ReplayFrame }) => b.frame.frameType !== 'delta';
|
|
1072
|
+
const size = (b: { imageData: string }) => b.imageData?.length ?? 0;
|
|
1073
|
+
|
|
1074
|
+
const keep: Array<{ frame: ReplayFrame; imageData: string }> = [];
|
|
1075
|
+
let budget = BackendSessionAdapter.MAX_PERSIST_BYTES;
|
|
1076
|
+
/**
|
|
1077
|
+
* A COUNT cap as well as a byte budget.
|
|
1078
|
+
*
|
|
1079
|
+
* The byte budget is the right bound for the KV write; it is the wrong bound for the upload,
|
|
1080
|
+
* because the server checks a count. Recovery now chunks, so exceeding this is no longer
|
|
1081
|
+
* destructive — but persisting more than can ever be sent in one pass just guarantees leftover
|
|
1082
|
+
* work on every launch, and the frames past this point are the least valuable ones anyway.
|
|
1083
|
+
*/
|
|
1084
|
+
const maxCount = BackendSessionAdapter.MAX_FRAMES_PER_BATCH;
|
|
1085
|
+
|
|
1086
|
+
// Keyframes first, newest back to oldest — a later keyframe supersedes an earlier one, so if
|
|
1087
|
+
// only one fits it should be the one closest to what the user last saw.
|
|
1088
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
1089
|
+
const b = pending[i];
|
|
1090
|
+
if (!isKeyframe(b)) continue;
|
|
1091
|
+
const cost = size(b);
|
|
1092
|
+
if (cost > budget || keep.length >= maxCount) continue;
|
|
1093
|
+
budget -= cost;
|
|
1094
|
+
keep.push(b);
|
|
1095
|
+
}
|
|
1096
|
+
// Then the most recent deltas with what remains.
|
|
1097
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
1098
|
+
const b = pending[i];
|
|
1099
|
+
if (isKeyframe(b)) continue;
|
|
1100
|
+
const cost = size(b);
|
|
1101
|
+
if (cost > budget || keep.length >= maxCount) continue;
|
|
1102
|
+
budget -= cost;
|
|
1103
|
+
keep.push(b);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Restore capture order: the backend orders frames by `sequenceNumber`, and the recovery POST
|
|
1107
|
+
// re-sends this array as-is.
|
|
1108
|
+
return keep.sort((a, b) => (a.frame.sequenceNumber ?? 0) - (b.frame.sequenceNumber ?? 0));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
918
1111
|
/** Drop the persisted frame snapshot (after successful delivery). Never throws. */
|
|
919
1112
|
private _clearPersistedFrames(): void {
|
|
920
1113
|
if (!this._frameStore) return;
|
|
@@ -958,18 +1151,33 @@ export class BackendSessionAdapter implements SessionTransport {
|
|
|
958
1151
|
return;
|
|
959
1152
|
}
|
|
960
1153
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1154
|
+
logger.info(`[replay.sdk.frame] recovering ${items.length} frame(s) for prior session ${sessionId}`);
|
|
1155
|
+
/**
|
|
1156
|
+
* DELETE ONLY WHAT WAS ACCEPTED.
|
|
1157
|
+
*
|
|
1158
|
+
* This posted every recovered frame in ONE request and, on any permanent rejection, deleted
|
|
1159
|
+
* the whole snapshot. Since the persistence budget is 2 MB of ~16 KB frames, that request was
|
|
1160
|
+
* routinely over the server's 100-frame cap — so the common outcome was a 4xx followed by the
|
|
1161
|
+
* deletion of a cross-kill recovery snapshot in which every frame was individually fine. The
|
|
1162
|
+
* one thing a recovery buffer must never do.
|
|
1163
|
+
*
|
|
1164
|
+
* Now the send is chunked and only the unaccepted remainder survives. A chunk the server will
|
|
1165
|
+
* never take is abandoned inside `_postFramesChunked` (and reported), so a single poison chunk
|
|
1166
|
+
* cannot wedge every future launch — but a transient failure keeps its frames for next time.
|
|
1167
|
+
*/
|
|
1168
|
+
const remaining = await this._postFramesChunked(sessionId, items);
|
|
1169
|
+
if (remaining.length === 0) {
|
|
964
1170
|
this._clearPersistedFrames();
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
logger.warn(`[replay.sdk.frame] prior-session frame recovery deferred`, { kept: remaining.length });
|
|
1174
|
+
try {
|
|
1175
|
+
this._frameStore.set(
|
|
1176
|
+
BackendSessionAdapter.PENDING_FRAMES_KEY,
|
|
1177
|
+
JSON.stringify({ sessionId, items: remaining }),
|
|
1178
|
+
);
|
|
1179
|
+
} catch {
|
|
1180
|
+
// Re-writing the trimmed snapshot is best-effort; the original is still on disk.
|
|
973
1181
|
}
|
|
974
1182
|
}
|
|
975
1183
|
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
|
|
17
17
|
export { default } from './public/ScaleBunFacade';
|
|
18
18
|
export { ScaleBunProvider } from './public/ScaleBunProvider';
|
|
19
|
+
/**
|
|
20
|
+
* Render-error boundary. Named and shaped to match `@scalebun/web-react`'s export so the same code
|
|
21
|
+
* works on both platforms — see the note in ScaleBunErrorBoundary.tsx for why a host boundary
|
|
22
|
+
* without this silently swallowed every React render error.
|
|
23
|
+
*/
|
|
24
|
+
export { ScaleBunErrorBoundary, type ScaleBunErrorBoundaryProps } from './public/ScaleBunErrorBoundary';
|
|
19
25
|
|
|
20
26
|
// ─── React Components & Hooks (consumer-facing) ────────────────────────────
|
|
21
27
|
// These are needed by app developers to wrap their app and track screens.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScaleBunErrorBoundary — React render errors, forwarded to the SDK.
|
|
3
|
+
*
|
|
4
|
+
* THE GAP THIS CLOSES
|
|
5
|
+
* `ErrorUtils.setGlobalHandler` catches a render error only when NO boundary intercepts it first.
|
|
6
|
+
* The moment a host wraps its tree in its own boundary — which most production apps do — that
|
|
7
|
+
* boundary's `componentDidCatch` consumes the error, it never reaches `ErrorUtils`, and the SDK
|
|
8
|
+
* recorded nothing at all. The screen went blank or fell back, and the crash list stayed empty.
|
|
9
|
+
*
|
|
10
|
+
* The web SDK has shipped `ScaleBunErrorBoundary` from `@scalebun/web-react` for exactly this reason;
|
|
11
|
+
* React Native had no equivalent and no `componentDidCatch` hook a host could forward through. This
|
|
12
|
+
* is the counterpart, and the two are deliberately the same component with the same prop names so a
|
|
13
|
+
* team working across both platforms writes the same thing twice.
|
|
14
|
+
*
|
|
15
|
+
* Use it INSTEAD of your own boundary, or nest it inside one — `onError` runs after capture so an
|
|
16
|
+
* existing boundary's logging keeps working.
|
|
17
|
+
*
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <ScaleBunErrorBoundary fallback={(e) => <Text>Something broke: {e.message}</Text>}>
|
|
20
|
+
* <App />
|
|
21
|
+
* </ScaleBunErrorBoundary>
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
25
|
+
import ScaleBun from './ScaleBunFacade';
|
|
26
|
+
|
|
27
|
+
export interface ScaleBunErrorBoundaryProps {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
/** Rendered after a caught error. A function receives the error; a node is rendered as-is. */
|
|
30
|
+
fallback?: ReactNode | ((error: Error) => ReactNode);
|
|
31
|
+
/** Extra hook after the error is captured (e.g. custom logging). */
|
|
32
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
33
|
+
/** Screen name to attribute the error to, when the host knows it. */
|
|
34
|
+
screenName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface State {
|
|
38
|
+
error: Error | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ScaleBunErrorBoundary extends Component<ScaleBunErrorBoundaryProps, State> {
|
|
42
|
+
state: State = { error: null };
|
|
43
|
+
|
|
44
|
+
static getDerivedStateFromError(error: Error): State {
|
|
45
|
+
return { error };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
49
|
+
try {
|
|
50
|
+
/**
|
|
51
|
+
* NOT fatal, matching the web boundary.
|
|
52
|
+
*
|
|
53
|
+
* The app is still running — that is the entire point of a boundary — so this is reported
|
|
54
|
+
* as a handled error rather than a crash. Marking it fatal would put an event the user
|
|
55
|
+
* experienced as a fallback screen into the same bucket as an app that actually died, and
|
|
56
|
+
* the crash rate is a number people act on.
|
|
57
|
+
*
|
|
58
|
+
* `componentStack` travels in `metadata` because that is the shape RN's `captureError`
|
|
59
|
+
* takes; the web SDK accepts it as a top-level context key. Same information, and the
|
|
60
|
+
* difference is in the two facades, not here.
|
|
61
|
+
*/
|
|
62
|
+
ScaleBun.captureError(error, {
|
|
63
|
+
screenName: this.props.screenName,
|
|
64
|
+
metadata: { componentStack: info.componentStack ?? undefined },
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
/* never let capture break the boundary */
|
|
68
|
+
}
|
|
69
|
+
this.props.onError?.(error, info);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render(): ReactNode {
|
|
73
|
+
if (this.state.error) {
|
|
74
|
+
const { fallback } = this.props;
|
|
75
|
+
return typeof fallback === 'function' ? fallback(this.state.error) : (fallback ?? null);
|
|
76
|
+
}
|
|
77
|
+
return this.props.children;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -73,6 +73,18 @@ export interface Spec extends TurboModule {
|
|
|
73
73
|
* at cold start, BEFORE JS loads.
|
|
74
74
|
*/
|
|
75
75
|
revertToPrevious(): Promise<boolean>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Restart the React Native runtime so the applied bundle loads NOW.
|
|
79
|
+
*
|
|
80
|
+
* Exists because `DevSettings.reload()` is a silent no-op in RELEASE builds
|
|
81
|
+
* (verified live: "Triggering app restart…" logged, nothing happened, same
|
|
82
|
+
* process, old bundle still running). That made IMMEDIATE / ON_NEXT_RESUME /
|
|
83
|
+
* `autoRestart` / the CodePush shim's restart all fiction in production.
|
|
84
|
+
* Android: `ReactHost.reload()` (bridgeless) or
|
|
85
|
+
* `recreateReactContextInBackground()` (bridge). iOS: RCTReloadCommand.
|
|
86
|
+
*/
|
|
87
|
+
restartApp(): void;
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
/**
|