@keepsnappy/react-native 0.1.0-alpha.13 → 0.1.0-alpha.15
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/expo/modules/keepsnappy/KeepSnappyModule.kt +47 -6
- package/ios/KeepSnappyModule.swift +41 -2
- package/js/babel-transformer.js +7 -3
- package/js/build-version.js +23 -0
- package/js/sdk/artifact-pending.ts +167 -0
- package/js/sdk/artifacts.ts +2 -1
- package/js/sdk/core.ts +100 -10
- package/js/sdk/push.ts +50 -15
- package/metro.js +2 -0
- package/package.json +1 -1
|
@@ -39,6 +39,9 @@ private const val FLUSH_ELEMENT_TAG = "keepsnappy.flushed.view"
|
|
|
39
39
|
private const val FLUSH_ELEMENT_DP = 8
|
|
40
40
|
// Alpha 1 of 255: drawn, so it counts as visible, but no pixel changes on screen.
|
|
41
41
|
private const val FLUSH_ELEMENT_BACKGROUND = 0x01000000
|
|
42
|
+
// No foreground activity or window yet (a relaunch, a link mid-transition): tried again this often, this far apart.
|
|
43
|
+
private const val FLUSH_ELEMENT_RETRIES = 3
|
|
44
|
+
private const val FLUSH_ELEMENT_RETRY_MS = 1000L
|
|
42
45
|
|
|
43
46
|
|
|
44
47
|
// logcat drops the tail of a line past ~4 KB, so long payloads go out in chunks.
|
|
@@ -79,7 +82,7 @@ class KeepSnappyModule : Module() {
|
|
|
79
82
|
// `am broadcast --es n 3` and `--ei n 3` both reach here.
|
|
80
83
|
@Suppress("DEPRECATION")
|
|
81
84
|
val n = intent.extras?.get("n")?.toString()?.toIntOrNull()
|
|
82
|
-
if (cmd != "gc" || n == null || n < 1) {
|
|
85
|
+
if ((cmd != "gc" && cmd != "memread") || n == null || n < 1) {
|
|
83
86
|
resultCode = Activity.RESULT_CANCELED
|
|
84
87
|
resultData = "bad command"
|
|
85
88
|
return
|
|
@@ -90,7 +93,7 @@ class KeepSnappyModule : Module() {
|
|
|
90
93
|
pendingCommands[n] = pending
|
|
91
94
|
mainHandler.postDelayed({
|
|
92
95
|
if (pendingCommands.remove(n, pending)) {
|
|
93
|
-
pending.setResult(Activity.RESULT_CANCELED, "
|
|
96
|
+
pending.setResult(Activity.RESULT_CANCELED, "$cmd:$n timeout", null)
|
|
94
97
|
pending.finish()
|
|
95
98
|
}
|
|
96
99
|
}, commandDeadlineMs)
|
|
@@ -114,19 +117,33 @@ class KeepSnappyModule : Module() {
|
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
private fun showFlushElement(status: String) {
|
|
120
|
+
private fun showFlushElement(status: String, detail: String, attempt: Int = 0) {
|
|
121
|
+
fun retry(reason: String) {
|
|
122
|
+
if (attempt < FLUSH_ELEMENT_RETRIES) {
|
|
123
|
+
mainHandler.postDelayed({ showFlushElement(status, detail, attempt + 1) }, FLUSH_ELEMENT_RETRY_MS)
|
|
124
|
+
} else {
|
|
125
|
+
writeLog("KEEPSNAPPY flush element not shown: $reason after ${attempt + 1} tries ($status)")
|
|
126
|
+
}
|
|
127
|
+
}
|
|
118
128
|
val activity = appContext.currentActivity
|
|
119
129
|
if (activity == null) {
|
|
120
|
-
|
|
130
|
+
retry("no foreground activity")
|
|
121
131
|
return
|
|
122
132
|
}
|
|
123
133
|
mainHandler.post {
|
|
124
|
-
val decor = activity.window?.decorView as? ViewGroup
|
|
134
|
+
val decor = activity.window?.decorView as? ViewGroup
|
|
135
|
+
if (decor == null) {
|
|
136
|
+
retry("no window")
|
|
137
|
+
return@post
|
|
138
|
+
}
|
|
125
139
|
decor.findViewWithTag<View>(FLUSH_ELEMENT_TAG)?.let { decor.removeView(it) }
|
|
126
140
|
val view = TextView(activity).apply {
|
|
127
141
|
tag = FLUSH_ELEMENT_TAG
|
|
128
142
|
text = status
|
|
129
143
|
setTextColor(Color.TRANSPARENT)
|
|
144
|
+
// The SDK's detail (what failed with `error`): UiAutomator2 reads it as the `hint` attribute; iOS has it as the value.
|
|
145
|
+
hint = detail
|
|
146
|
+
setHintTextColor(Color.TRANSPARENT)
|
|
130
147
|
// UiAutomator2 drops nodes Android reports as not visible to the user, and a 1 px view in the corner under the system bars was one. Measured on a Galaxy S23 (Android 16): found at 8 dp on the left edge, middle of the screen.
|
|
131
148
|
setBackgroundColor(FLUSH_ELEMENT_BACKGROUND)
|
|
132
149
|
contentDescription = FLUSH_ELEMENT_ID
|
|
@@ -380,7 +397,7 @@ class KeepSnappyModule : Module() {
|
|
|
380
397
|
// like iOS it stays until the next flush replaces it or the process ends. Always logged too.
|
|
381
398
|
Function("flushSignal") { status: String, detail: String ->
|
|
382
399
|
writeLog("KEEPSNAPPY flushed $status $detail")
|
|
383
|
-
showFlushElement(status)
|
|
400
|
+
showFlushElement(status, detail)
|
|
384
401
|
}
|
|
385
402
|
|
|
386
403
|
Function("spoolAppend") { record: String ->
|
|
@@ -413,6 +430,30 @@ class KeepSnappyModule : Module() {
|
|
|
413
430
|
}.get()
|
|
414
431
|
}
|
|
415
432
|
|
|
433
|
+
// The profile files a flush has not uploaded yet, with their run tokens: internal storage, like the push journal.
|
|
434
|
+
Function("artifactsPendingRead") {
|
|
435
|
+
io.submit<String?> {
|
|
436
|
+
val file = stateDir(false)?.let { File(it, "artifacts-pending.json") }
|
|
437
|
+
if (file != null && file.exists()) {
|
|
438
|
+
try { file.readText() } catch (e: Exception) { null }
|
|
439
|
+
} else {
|
|
440
|
+
null
|
|
441
|
+
}
|
|
442
|
+
}.get()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
Function("artifactsPendingWrite") { content: String ->
|
|
446
|
+
io.submit<Boolean> {
|
|
447
|
+
val dir = stateDir(true) ?: return@submit false
|
|
448
|
+
val file = File(dir, "artifacts-pending.json")
|
|
449
|
+
if (content.isEmpty()) {
|
|
450
|
+
!file.exists() || file.delete()
|
|
451
|
+
} else {
|
|
452
|
+
writeAtomically(file, content)
|
|
453
|
+
}
|
|
454
|
+
}.get()
|
|
455
|
+
}
|
|
456
|
+
|
|
416
457
|
// System.nanoTime() is CLOCK_MONOTONIC on Android, the same clock as
|
|
417
458
|
// performance.now() in RN and the MONOTONIC snapshot in Perfetto.
|
|
418
459
|
Function("clockSnapshot") {
|
|
@@ -139,6 +139,31 @@ public class KeepSnappyModule: Module {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// The profile files a flush has not uploaded yet, with their run tokens; read by a later process.
|
|
143
|
+
Function("artifactsPendingRead") { () -> String? in
|
|
144
|
+
self.logQueue.sync {
|
|
145
|
+
guard let file = Self.fileIfPresent("artifacts-pending.json") else { return nil }
|
|
146
|
+
return try? String(contentsOf: file, encoding: .utf8)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
Function("artifactsPendingWrite") { (content: String) -> Bool in
|
|
151
|
+
self.logQueue.sync {
|
|
152
|
+
guard let dir = Self.outputDir() else { return false }
|
|
153
|
+
let file = dir.appendingPathComponent("artifacts-pending.json")
|
|
154
|
+
if content.isEmpty {
|
|
155
|
+
try? FileManager.default.removeItem(at: file)
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
do {
|
|
159
|
+
try content.write(to: file, atomically: true, encoding: .utf8)
|
|
160
|
+
return true
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
Function("spoolReplace") { (content: String) -> Bool in
|
|
143
168
|
self.logQueue.sync {
|
|
144
169
|
try? self.spoolHandle?.close()
|
|
@@ -296,12 +321,26 @@ public class KeepSnappyModule: Module {
|
|
|
296
321
|
private static let flushSignalTag = 0x9E7F_1A5
|
|
297
322
|
private static let ackSignalTag = 0x9E7F_1A6
|
|
298
323
|
|
|
324
|
+
// No window yet (a relaunch, a link mid-transition): tried again this often, this far apart.
|
|
325
|
+
private static let signalRetries = 3
|
|
326
|
+
private static let signalRetryNanoseconds: UInt64 = 1_000_000_000
|
|
327
|
+
|
|
299
328
|
@MainActor
|
|
300
|
-
private static func showSignal(tag: Int, id: String, label status: String, value detail: String) {
|
|
329
|
+
private static func showSignal(tag: Int, id: String, label status: String, value detail: String, attempt: Int = 0) {
|
|
301
330
|
let windows = UIApplication.shared.connectedScenes
|
|
302
331
|
.compactMap { $0 as? UIWindowScene }
|
|
303
332
|
.flatMap { $0.windows }
|
|
304
|
-
guard let window = windows.first(where: { $0.isKeyWindow }) ?? windows.first else {
|
|
333
|
+
guard let window = windows.first(where: { $0.isKeyWindow }) ?? windows.first else {
|
|
334
|
+
if attempt < signalRetries {
|
|
335
|
+
Task { @MainActor in
|
|
336
|
+
try? await Task.sleep(nanoseconds: signalRetryNanoseconds)
|
|
337
|
+
Self.showSignal(tag: tag, id: id, label: status, value: detail, attempt: attempt + 1)
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
emit("KEEPSNAPPY \(id) not shown: no window after \(attempt + 1) tries (\(status))")
|
|
341
|
+
}
|
|
342
|
+
return
|
|
343
|
+
}
|
|
305
344
|
window.viewWithTag(tag)?.removeFromSuperview()
|
|
306
345
|
let label = UILabel(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
307
346
|
label.tag = tag
|
package/js/babel-transformer.js
CHANGED
|
@@ -11,8 +11,9 @@ const path = require('path');
|
|
|
11
11
|
const upstreamPath = (process.env.KEEPSNAPPY_UPSTREAM_BABEL_TRANSFORMER);
|
|
12
12
|
const entryFile = (process.env.KEEPSNAPPY_ENTRY_FILE);
|
|
13
13
|
const bootFile = (process.env.KEEPSNAPPY_BOOT_FILE);
|
|
14
|
+
const sdkVersion = process.env.KEEPSNAPPY_SDK_VERSION;
|
|
14
15
|
|
|
15
|
-
if (!upstreamPath || !entryFile || !bootFile) {
|
|
16
|
+
if (!upstreamPath || !entryFile || !bootFile || !sdkVersion) {
|
|
16
17
|
throw new Error(
|
|
17
18
|
'KeepSnappy Babel transformer loaded without withKeepSnappy env (KEEPSNAPPY_*)',
|
|
18
19
|
);
|
|
@@ -57,7 +58,10 @@ module.exports = {
|
|
|
57
58
|
return upstream.transform({ ...args, src: bootImport() + args.src });
|
|
58
59
|
}
|
|
59
60
|
if (file.startsWith(packageRoot)) {
|
|
60
|
-
|
|
61
|
+
const src = file === path.join(packageRoot, 'js', 'sdk', 'push-install.ts')
|
|
62
|
+
? args.src.replace("require('../../package.json')", JSON.stringify({ version: sdkVersion }))
|
|
63
|
+
: args.src;
|
|
64
|
+
return upstream.transform({ ...args, src: inlineEnv(src) });
|
|
61
65
|
}
|
|
62
66
|
return upstream.transform(args);
|
|
63
67
|
},
|
|
@@ -66,6 +70,6 @@ module.exports = {
|
|
|
66
70
|
typeof upstream.getCacheKey === 'function'
|
|
67
71
|
? upstream.getCacheKey(...args)
|
|
68
72
|
: '';
|
|
69
|
-
return `${base}$keepsnappy:${entryFile}:${bootFile}:${envSignature()}`;
|
|
73
|
+
return `${base}$keepsnappy:${entryFile}:${bootFile}:${sdkVersion}:${envSignature()}`;
|
|
70
74
|
},
|
|
71
75
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Build tooling only: Metro workers receive the identity in their inherited env.
|
|
2
|
+
const { execFileSync } = require('child_process');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function sdkBuildVersion() {
|
|
7
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
8
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
9
|
+
if (!pkg.private) return pkg.version;
|
|
10
|
+
const git = args => execFileSync('git', args, { cwd: packageRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
11
|
+
try {
|
|
12
|
+
const root = git(['rev-parse', '--show-toplevel']);
|
|
13
|
+
const release = JSON.parse(fs.readFileSync(path.join(root, 'release.json'), 'utf8')).version;
|
|
14
|
+
if (typeof release !== 'string' || !release) throw new Error('release.json has no version');
|
|
15
|
+
const sha = git(['rev-parse', '--short', 'HEAD']);
|
|
16
|
+
const dirty = git(['status', '--porcelain', '--untracked-files=normal']) ? '.dirty' : '';
|
|
17
|
+
return `${release}+local.${sha}${dirty}`;
|
|
18
|
+
} catch (cause) {
|
|
19
|
+
throw new Error('KeepSnappy: cannot identify this unpublished SDK; build from its Git checkout or use a staged release package', { cause });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { sdkBuildVersion };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Profile files a flush has not uploaded yet, kept in the app's internal storage with their run tokens (next to the push
|
|
2
|
+
// journal), so a later process uploads them: a flush that never finished, a process killed in the middle of the upload.
|
|
3
|
+
// A process's whole list is kept, uploaded files too: the run's flush report counts every file the run made.
|
|
4
|
+
|
|
5
|
+
import type { ArtifactEntry, ArtifactKind, ArtifactReason, ArtifactState } from './artifacts';
|
|
6
|
+
|
|
7
|
+
export interface PendingArtifactStore {
|
|
8
|
+
read(): string | null;
|
|
9
|
+
// '' removes the record.
|
|
10
|
+
write(content: string): boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SavedEntry {
|
|
14
|
+
name: string;
|
|
15
|
+
kind: ArtifactKind;
|
|
16
|
+
reasons: ArtifactReason[];
|
|
17
|
+
windows: number[];
|
|
18
|
+
bytes: number | null;
|
|
19
|
+
runToken: string | null;
|
|
20
|
+
state: ArtifactState;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SavedGroup {
|
|
24
|
+
processId: string;
|
|
25
|
+
// Flushes of later processes that tried this group.
|
|
26
|
+
tries: number;
|
|
27
|
+
entries: SavedEntry[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EarlierArtifacts {
|
|
31
|
+
processId: string;
|
|
32
|
+
entries: ArtifactEntry[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A file whose upload fails at this many later flushes (missing on disk, refused) stops being tried.
|
|
36
|
+
export const PENDING_ARTIFACT_TRIES = 3;
|
|
37
|
+
|
|
38
|
+
const owed = (state: ArtifactState) => state === 'pending' || state === 'failed';
|
|
39
|
+
|
|
40
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
41
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
42
|
+
|
|
43
|
+
const savedEntry = (value: unknown): SavedEntry | null => {
|
|
44
|
+
if (!isObject(value) || typeof value.name !== 'string' || typeof value.kind !== 'string' || typeof value.state !== 'string') {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
name: value.name,
|
|
49
|
+
kind: value.kind as ArtifactKind,
|
|
50
|
+
reasons: Array.isArray(value.reasons) ? (value.reasons as ArtifactReason[]) : [],
|
|
51
|
+
windows: Array.isArray(value.windows) ? (value.windows as number[]) : [],
|
|
52
|
+
bytes: typeof value.bytes === 'number' ? value.bytes : null,
|
|
53
|
+
runToken: typeof value.runToken === 'string' ? value.runToken : null,
|
|
54
|
+
state: value.state as ArtifactState,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const parse = (raw: string | null): SavedGroup[] => {
|
|
59
|
+
if (!raw) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const value = JSON.parse(raw) as unknown;
|
|
64
|
+
if (!isObject(value) || value.v !== 1 || !Array.isArray(value.groups)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return value.groups.flatMap(group => {
|
|
68
|
+
if (!isObject(group) || typeof group.processId !== 'string' || !Array.isArray(group.entries)) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const entries = group.entries.map(savedEntry).filter((e): e is SavedEntry => e !== null);
|
|
72
|
+
return [{ processId: group.processId, tries: typeof group.tries === 'number' ? group.tries : 0, entries }];
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const toSaved = (entry: ArtifactEntry, token: string | null): SavedEntry => ({
|
|
80
|
+
name: entry.name,
|
|
81
|
+
kind: entry.kind,
|
|
82
|
+
reasons: entry.reasons,
|
|
83
|
+
windows: entry.windows,
|
|
84
|
+
bytes: entry.bytes,
|
|
85
|
+
runToken: entry.runToken !== undefined ? entry.runToken : token,
|
|
86
|
+
state: entry.state,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const toEntry = (saved: SavedEntry): ArtifactEntry => ({
|
|
90
|
+
name: saved.name,
|
|
91
|
+
kind: saved.kind,
|
|
92
|
+
reasons: saved.reasons,
|
|
93
|
+
windows: saved.windows,
|
|
94
|
+
bytes: saved.bytes,
|
|
95
|
+
state: owed(saved.state) ? 'pending' : saved.state,
|
|
96
|
+
artifactId: null,
|
|
97
|
+
gzBytes: null,
|
|
98
|
+
sha256: null,
|
|
99
|
+
attempts: 0,
|
|
100
|
+
error: null,
|
|
101
|
+
runToken: saved.runToken,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// `store` is looked up at each call: null (an older binary) keeps nothing, as before.
|
|
105
|
+
export const createPendingArtifacts = (store: () => PendingArtifactStore | null, log: (message: string) => void) => {
|
|
106
|
+
const read = () => {
|
|
107
|
+
try {
|
|
108
|
+
return parse(store()?.read() ?? null);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
log(`artifacts pending read failed ${String(error)}`);
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const save = (groups: SavedGroup[]) => {
|
|
115
|
+
const target = store();
|
|
116
|
+
if (!target) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const kept = groups.filter(group => group.entries.some(entry => owed(entry.state)));
|
|
120
|
+
try {
|
|
121
|
+
target.write(kept.length > 0 ? JSON.stringify({ v: 1, groups: kept }) : '');
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log(`artifacts pending write failed ${String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
// This process's files as they stand now; replaces its earlier record.
|
|
129
|
+
note(processId: string, entries: ArtifactEntry[], token: string | null) {
|
|
130
|
+
if (!store()) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const groups = read().filter(group => group.processId !== processId);
|
|
134
|
+
groups.push({ processId, tries: 0, entries: entries.map(entry => toSaved(entry, token)) });
|
|
135
|
+
save(groups);
|
|
136
|
+
},
|
|
137
|
+
// Earlier processes' lists, as entries the uploader takes: what is owed is pending again.
|
|
138
|
+
earlier(processId: string): EarlierArtifacts[] {
|
|
139
|
+
return read()
|
|
140
|
+
.filter(group => group.processId !== processId)
|
|
141
|
+
.map(group => ({ processId: group.processId, entries: group.entries.map(toEntry) }));
|
|
142
|
+
},
|
|
143
|
+
// After those were tried: what is still owed stays, for at most PENDING_ARTIFACT_TRIES flushes.
|
|
144
|
+
settleEarlier(tried: EarlierArtifacts[]) {
|
|
145
|
+
const byProcess = new Map(tried.map(group => [group.processId, group]));
|
|
146
|
+
const groups: SavedGroup[] = [];
|
|
147
|
+
for (const group of read()) {
|
|
148
|
+
const done = byProcess.get(group.processId);
|
|
149
|
+
if (!done) {
|
|
150
|
+
groups.push(group);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const entries = done.entries.map(entry => toSaved(entry, null));
|
|
154
|
+
const left = entries.filter(entry => owed(entry.state));
|
|
155
|
+
// A try is an upload that was attempted and failed; a file the deadline stopped first was not tried.
|
|
156
|
+
const attempted = done.entries.some(entry => owed(entry.state) && entry.attempts > 0);
|
|
157
|
+
const tries = attempted ? group.tries + 1 : group.tries;
|
|
158
|
+
if (left.length > 0 && tries >= PENDING_ARTIFACT_TRIES) {
|
|
159
|
+
log(`artifacts earlier dropped ${JSON.stringify({ processId: group.processId, tries, files: left.map(entry => entry.name) })}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
groups.push({ processId: group.processId, tries, entries });
|
|
163
|
+
}
|
|
164
|
+
save(groups);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
};
|
package/js/sdk/artifacts.ts
CHANGED
|
@@ -622,7 +622,8 @@ export const createArtifactUploader = (deps: ArtifactUploaderDeps) => {
|
|
|
622
622
|
deps.log(`artifacts upload stopped: ${reported}`);
|
|
623
623
|
}
|
|
624
624
|
const summary = summarizeArtifacts(entries, deps.now() - t0, reported);
|
|
625
|
-
|
|
625
|
+
// At most one request timeout past the flush deadline, however late the upload ran: ARTIFACT_FLUSH_WORST_CASE_MS holds.
|
|
626
|
+
await report(entries, token, 'end', Math.min(deps.now(), deadline) + requestTimeoutMs, reported);
|
|
626
627
|
return summary;
|
|
627
628
|
};
|
|
628
629
|
|
package/js/sdk/core.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
summarizeArtifacts,
|
|
33
33
|
type WrittenFile,
|
|
34
34
|
} from './artifacts';
|
|
35
|
+
import { createPendingArtifacts, type EarlierArtifacts } from './artifact-pending';
|
|
35
36
|
import {
|
|
36
37
|
backgroundSource,
|
|
37
38
|
type Carrier,
|
|
@@ -143,7 +144,10 @@ interface KeepSnappyNative extends Partial<CpuProfileNative> {
|
|
|
143
144
|
samplerRecent(sinceT: number): SamplerRecord[];
|
|
144
145
|
samplerNow(): number;
|
|
145
146
|
flushSignal?(status: string, detail: string): void;
|
|
146
|
-
//
|
|
147
|
+
// The list of profile files not uploaded yet, with run tokens: internal storage, read by a later process.
|
|
148
|
+
artifactsPendingRead?(): string | null;
|
|
149
|
+
artifactsPendingWrite?(content: string): boolean;
|
|
150
|
+
// Command answer (`gc:<n> ok`, `memread:<n> ok`): Android finishes the pending command broadcast with this result; iOS shows it on `keepsnappy.ack`.
|
|
147
151
|
commandDone?(n: number, data: string): void;
|
|
148
152
|
/** Android: whether the broadcast of command n still waits for its answer (native finishes it at a deadline). */
|
|
149
153
|
commandPending?(n: number): boolean;
|
|
@@ -1617,6 +1621,17 @@ export const setArtifactHandler = (handler: ArtifactHandler | null) => {
|
|
|
1617
1621
|
// Entries a window interrupted wait for the next flush of this process.
|
|
1618
1622
|
let artifactBacklog: ArtifactEntry[] = [];
|
|
1619
1623
|
|
|
1624
|
+
const pendingArtifacts = createPendingArtifacts(() => {
|
|
1625
|
+
const native = getNative();
|
|
1626
|
+
if (typeof native?.artifactsPendingRead !== 'function' || typeof native.artifactsPendingWrite !== 'function') {
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
return {
|
|
1630
|
+
read: () => native.artifactsPendingRead!(),
|
|
1631
|
+
write: content => native.artifactsPendingWrite!(content),
|
|
1632
|
+
};
|
|
1633
|
+
}, perfLog);
|
|
1634
|
+
|
|
1620
1635
|
const writeManifest = (entries: ArtifactEntry[], upload: ArtifactUploadSummary | null) => {
|
|
1621
1636
|
try {
|
|
1622
1637
|
const manifest = buildManifest({
|
|
@@ -1662,11 +1677,39 @@ const sendArtifacts = async (fresh: ArtifactEntry[], deadline: number) => {
|
|
|
1662
1677
|
summary = summarizeArtifacts(entries, 0, 'push not configured');
|
|
1663
1678
|
}
|
|
1664
1679
|
artifactBacklog = entries.filter(entry => entry.state === 'pending' || entry.state === 'failed');
|
|
1680
|
+
pendingArtifacts.note(PROCESS_ID, entries, runToken);
|
|
1665
1681
|
writeManifest(entries, summary);
|
|
1666
1682
|
perfLog(`artifacts ${JSON.stringify(summary)}`);
|
|
1667
1683
|
return summary;
|
|
1668
1684
|
};
|
|
1669
1685
|
|
|
1686
|
+
// After this process's own files: what earlier processes left (a flush that never finished, a kill during the upload), each
|
|
1687
|
+
// under its own run token and process id, within the same deadline.
|
|
1688
|
+
const sendEarlierArtifacts = async (deadline: number) => {
|
|
1689
|
+
if (!artifactHandler) {
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
const groups: EarlierArtifacts[] = pendingArtifacts.earlier(PROCESS_ID);
|
|
1693
|
+
if (groups.length === 0) {
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const tried: EarlierArtifacts[] = [];
|
|
1697
|
+
for (const group of groups) {
|
|
1698
|
+
// What does not fit in this flush waits for the next one, with no try counted.
|
|
1699
|
+
if (Date.now() >= deadline) {
|
|
1700
|
+
break;
|
|
1701
|
+
}
|
|
1702
|
+
tried.push(group);
|
|
1703
|
+
try {
|
|
1704
|
+
const summary = await artifactHandler(group.entries, null, group.processId, deadline);
|
|
1705
|
+
perfLog(`artifacts earlier ${JSON.stringify({ processId: group.processId, ...summary })}`);
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
perfLog(`artifacts earlier failed ${String(error)}`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
pendingArtifacts.settleEarlier(tried);
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1670
1713
|
// Declared on every window line so the backend can tell windows of this model from older ones (REQ-VER-34).
|
|
1671
1714
|
// 2: a tap or scroll window runs from its touch to the next touch, the background or the flush; no quiet rule, no `+3s` twin.
|
|
1672
1715
|
// 3: model 2, and a screen window also ends at the next action, the next screen, the background or the flush; no 3 s cap.
|
|
@@ -1997,7 +2040,11 @@ export const setFlushHandler = (handler: FlushHandler | null) => {
|
|
|
1997
2040
|
// `flush=1`: end the open windows, send every unsent line, then show the result
|
|
1998
2041
|
// to the test driver as an accessibility element (iOS: id `keepsnappy.flushed`,
|
|
1999
2042
|
// label `ok` / `error` / `off`), so a flow can wait for it without network access.
|
|
2000
|
-
|
|
2043
|
+
// The driver's name for one flush link (`ack=<n>`), echoed at the end of the answer's detail: the answer element stays up
|
|
2044
|
+
// until the next one replaces it, so without it a later flush in the same process could read an earlier flush's answer.
|
|
2045
|
+
const ACK_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
|
|
2046
|
+
|
|
2047
|
+
const flushOnce = async (ack: string | undefined) => {
|
|
2001
2048
|
// One deadline for the whole flush (lines, clock, artifacts); the end report of the upload comes after it.
|
|
2002
2049
|
const deadline = Date.now() + ARTIFACT_UPLOAD_DEADLINE_MS;
|
|
2003
2050
|
endAll('flush');
|
|
@@ -2008,6 +2055,8 @@ const flushOnce = async () => {
|
|
|
2008
2055
|
} catch (error) {
|
|
2009
2056
|
perfLog(`flush files failed ${String(error)}`);
|
|
2010
2057
|
}
|
|
2058
|
+
// Before anything that waits on the network: a flush that never ends, or a process killed now, leaves the list to a later one.
|
|
2059
|
+
pendingArtifacts.note(PROCESS_ID, [...artifactBacklog, ...artifacts], runToken);
|
|
2011
2060
|
let status = 'off';
|
|
2012
2061
|
let detail = 'push not configured';
|
|
2013
2062
|
if (flushHandler) {
|
|
@@ -2026,10 +2075,25 @@ const flushOnce = async () => {
|
|
|
2026
2075
|
const sent = await sendArtifacts(artifacts, deadline);
|
|
2027
2076
|
if (sent) {
|
|
2028
2077
|
detail += ` artifacts=${sent.uploaded}/${sent.total}`;
|
|
2078
|
+
// A profile file of a run that did not reach the API is missing data, whatever kept it back: failed after its tries, left
|
|
2079
|
+
// unsent by the deadline, kept on the device (token refused, no endpoint, no native upload) or refused by the API.
|
|
2080
|
+
const missing = sent.failed + sent.pending + (runToken !== null ? sent.local + sent.rejected : 0);
|
|
2081
|
+
if (status === 'ok' && missing > 0) {
|
|
2082
|
+
status = 'error';
|
|
2083
|
+
detail += ` failed=${sent.failed} pending=${sent.pending} local=${sent.local} rejected=${sent.rejected}`;
|
|
2084
|
+
}
|
|
2029
2085
|
}
|
|
2030
2086
|
} catch (error) {
|
|
2031
2087
|
perfLog(`artifacts failed ${String(error)}`);
|
|
2032
2088
|
}
|
|
2089
|
+
try {
|
|
2090
|
+
await sendEarlierArtifacts(deadline);
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
perfLog(`artifacts earlier failed ${String(error)}`);
|
|
2093
|
+
}
|
|
2094
|
+
if (ack !== undefined && ACK_PATTERN.test(ack)) {
|
|
2095
|
+
detail += ` ack=${ack}`;
|
|
2096
|
+
}
|
|
2033
2097
|
perfLog(`flush ${status} ${detail}`);
|
|
2034
2098
|
try {
|
|
2035
2099
|
getNative()?.flushSignal?.(status, detail);
|
|
@@ -2041,11 +2105,11 @@ const flushOnce = async () => {
|
|
|
2041
2105
|
// A second flush link while one runs (the driver's final flush) waits for it: the flush signal means the
|
|
2042
2106
|
// uploads of the first are done, so it must not be raised by a flush that has nothing left to send.
|
|
2043
2107
|
let flushing: Promise<void> | null = null;
|
|
2044
|
-
export const flushNow = (): Promise<void> => {
|
|
2108
|
+
export const flushNow = (ack?: string): Promise<void> => {
|
|
2045
2109
|
commandAnswers.clear();
|
|
2046
2110
|
gcRanSinceFlush = false;
|
|
2047
2111
|
// The first flush starts at once (it ends the open windows in the same tick as the link).
|
|
2048
|
-
const run = flushing === null ? flushOnce() : flushing.then(flushOnce);
|
|
2112
|
+
const run = flushing === null ? flushOnce(ack) : flushing.then(() => flushOnce(ack));
|
|
2049
2113
|
const tail: Promise<void> = run
|
|
2050
2114
|
.catch(() => undefined)
|
|
2051
2115
|
.then(() => {
|
|
@@ -2242,10 +2306,32 @@ export const actionGc = (n: number, tNative?: number): string => {
|
|
|
2242
2306
|
}
|
|
2243
2307
|
// The gc-pre / gc-post records go out now: the backend pairs them with this command by their seq.
|
|
2244
2308
|
forwardSamples();
|
|
2309
|
+
rememberAnswer(key, answer);
|
|
2310
|
+
answerCommand(n, answer);
|
|
2311
|
+
return answer;
|
|
2312
|
+
};
|
|
2313
|
+
|
|
2314
|
+
const rememberAnswer = (key: string, answer: string) => {
|
|
2245
2315
|
commandAnswers.set(key, answer);
|
|
2246
2316
|
if (commandAnswers.size > COMMAND_ANSWERS_KEPT) {
|
|
2247
2317
|
commandAnswers.delete(commandAnswers.keys().next().value as string);
|
|
2248
2318
|
}
|
|
2319
|
+
};
|
|
2320
|
+
|
|
2321
|
+
// The end GC's manual memory read: the driver's `memread` command, sent with the GC's own n after the GC's answer. One sample, at
|
|
2322
|
+
// once (no timer), reason `endgc-read:<n>`, sent with the run's lines before the answer; the answer is the driver's signal that the
|
|
2323
|
+
// app may go to the background. Derive reads kept from this record (derivation 23).
|
|
2324
|
+
export const endGcRead = (n: number): string => {
|
|
2325
|
+
const key = `${runToken ?? '-'}#read${n}`;
|
|
2326
|
+
const known = commandAnswers.get(key);
|
|
2327
|
+
if (known !== undefined) {
|
|
2328
|
+
answerCommand(n, known);
|
|
2329
|
+
return known;
|
|
2330
|
+
}
|
|
2331
|
+
const record = sample(`endgc-read:${n}`);
|
|
2332
|
+
const answer = `memread:${n} ${record ? 'ok' : 'unavailable'}`;
|
|
2333
|
+
forwardSamples();
|
|
2334
|
+
rememberAnswer(key, answer);
|
|
2249
2335
|
answerCommand(n, answer);
|
|
2250
2336
|
return answer;
|
|
2251
2337
|
};
|
|
@@ -2264,7 +2350,7 @@ const syncCommandListener = () => {
|
|
|
2264
2350
|
native.addListener?.('keepsnappyCommand', e => {
|
|
2265
2351
|
// A command native already timed out on is not run: its GC would land in the next step's action.
|
|
2266
2352
|
if (native.commandPending?.(e.n) === false) {
|
|
2267
|
-
perfLog(`command
|
|
2353
|
+
perfLog(`command ${e.cmd}:${e.n} late, skipped`);
|
|
2268
2354
|
return;
|
|
2269
2355
|
}
|
|
2270
2356
|
handleKeepSnappyCommand(e.cmd, e.n, e.tNative);
|
|
@@ -2275,15 +2361,19 @@ const syncCommandListener = () => {
|
|
|
2275
2361
|
|
|
2276
2362
|
/** A command (`cmd=<name>&n=<n>`), from a link (iOS) or the native receiver (Android); unknown commands are ignored. */
|
|
2277
2363
|
export const handleKeepSnappyCommand = (cmd: string | undefined, n: number, tNative?: number): boolean => {
|
|
2278
|
-
if (!KEEPSNAPPY_ENABLED || cmd !== 'gc' || !Number.isInteger(n) || n < 1) {
|
|
2364
|
+
if (!KEEPSNAPPY_ENABLED || (cmd !== 'gc' && cmd !== 'memread') || !Number.isInteger(n) || n < 1) {
|
|
2279
2365
|
return false;
|
|
2280
2366
|
}
|
|
2281
2367
|
// Every received command is answered, so the Android receiver is never left pending.
|
|
2282
2368
|
try {
|
|
2283
|
-
|
|
2369
|
+
if (cmd === 'gc') {
|
|
2370
|
+
actionGc(n, tNative);
|
|
2371
|
+
} else {
|
|
2372
|
+
endGcRead(n);
|
|
2373
|
+
}
|
|
2284
2374
|
} catch (error) {
|
|
2285
|
-
perfLog(`command
|
|
2286
|
-
answerCommand(n,
|
|
2375
|
+
perfLog(`command ${cmd}:${n} failed ${String(error)}`);
|
|
2376
|
+
answerCommand(n, `${cmd}:${n} error`);
|
|
2287
2377
|
}
|
|
2288
2378
|
return true;
|
|
2289
2379
|
};
|
|
@@ -2306,7 +2396,7 @@ export const handleKeepSnappyDeepLink = (url: string) => {
|
|
|
2306
2396
|
}
|
|
2307
2397
|
applySettings(params);
|
|
2308
2398
|
if (params.flush === '1') {
|
|
2309
|
-
void flushNow();
|
|
2399
|
+
void flushNow(params.ack);
|
|
2310
2400
|
}
|
|
2311
2401
|
};
|
|
2312
2402
|
|
package/js/sdk/push.ts
CHANGED
|
@@ -95,9 +95,13 @@ export interface ClockSample {
|
|
|
95
95
|
|
|
96
96
|
const MAX_CLOCK_SAMPLES = 16;
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// `bound` rejects when the request's time is up: a native fetch resolves at the headers, and its body can stall with the
|
|
99
|
+
// abort signal ignored.
|
|
100
|
+
const serverClock = async (res: { json?: () => Promise<unknown> }, bound: Promise<never>) => {
|
|
99
101
|
try {
|
|
100
|
-
const body = (await res.json?.()) as
|
|
102
|
+
const body = (await Promise.race([res.json?.(), bound])) as
|
|
103
|
+
| { clock?: { recv_ms?: unknown; send_ms?: unknown } }
|
|
104
|
+
| undefined;
|
|
101
105
|
const t1 = body?.clock?.recv_ms;
|
|
102
106
|
const t2 = body?.clock?.send_ms;
|
|
103
107
|
return typeof t1 === 'number' && typeof t2 === 'number' ? { t1, t2 } : null;
|
|
@@ -240,6 +244,9 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
240
244
|
const endpoint = `${deps.url.replace(/\/+$/, '')}/v1/windows`;
|
|
241
245
|
// Set by flushAll: the time (deps.now) every request of that flush must be over by.
|
|
242
246
|
let flushLimit: number | null = null;
|
|
247
|
+
// Batches of a run (with a token) the ingest refused for good: their lines are gone, so the run lacks data.
|
|
248
|
+
let refusedRunBatches = 0;
|
|
249
|
+
let lastRefusedStatus = 0;
|
|
243
250
|
// Rejects after `ms`; a hard bound for a fetch that ignores its abort signal.
|
|
244
251
|
const timeoutAfter = (ms: number) => {
|
|
245
252
|
let handle: unknown;
|
|
@@ -603,7 +610,12 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
603
610
|
sending = true;
|
|
604
611
|
try {
|
|
605
612
|
if (!device) {
|
|
606
|
-
|
|
613
|
+
const bound = timeoutAfter(flushLimit !== null ? Math.max(1, Math.min(timeoutMs, flushLimit - deps.now())) : timeoutMs);
|
|
614
|
+
try {
|
|
615
|
+
device = await Promise.race([deps.getDevice(), bound.promise]);
|
|
616
|
+
} finally {
|
|
617
|
+
bound.clear();
|
|
618
|
+
}
|
|
607
619
|
}
|
|
608
620
|
if (!batch.id) {
|
|
609
621
|
// Names exactly these lines (process, first and last sequence, count),
|
|
@@ -641,7 +653,8 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
641
653
|
const abort = controller
|
|
642
654
|
? deps.setTimer(() => controller.abort(), budget)
|
|
643
655
|
: null;
|
|
644
|
-
|
|
656
|
+
// Every request, idle or flush: a flush link waits for an idle send still in flight.
|
|
657
|
+
const hard = timeoutAfter(budget);
|
|
645
658
|
let status = 0;
|
|
646
659
|
try {
|
|
647
660
|
const t0 = deps.now();
|
|
@@ -651,16 +664,16 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
651
664
|
body,
|
|
652
665
|
...(controller ? { signal: controller.signal } : {}),
|
|
653
666
|
});
|
|
654
|
-
const res =
|
|
667
|
+
const res = await Promise.race([pending, hard.promise]);
|
|
655
668
|
const t3 = deps.now();
|
|
656
669
|
status = res.status;
|
|
670
|
+
// A fresh batch (202) stored the samples it carried, whether or not its body arrives; a duplicate's go again.
|
|
671
|
+
if (status === 202) {
|
|
672
|
+
dropClock(reported);
|
|
673
|
+
}
|
|
657
674
|
if (runToken && (status === 200 || status === 202)) {
|
|
658
|
-
const server = await serverClock(res);
|
|
675
|
+
const server = await serverClock(res, hard.promise);
|
|
659
676
|
if (server) {
|
|
660
|
-
// Stored only by a fresh batch; a duplicate's samples go again.
|
|
661
|
-
if (status === 202) {
|
|
662
|
-
dropClock(reported);
|
|
663
|
-
}
|
|
664
677
|
keepClock(runToken, { t0, ...server, t3 });
|
|
665
678
|
}
|
|
666
679
|
}
|
|
@@ -668,7 +681,7 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
668
681
|
if (abort !== null) {
|
|
669
682
|
deps.clearTimer(abort);
|
|
670
683
|
}
|
|
671
|
-
hard
|
|
684
|
+
hard.clear();
|
|
672
685
|
}
|
|
673
686
|
stats.lastStatus = status;
|
|
674
687
|
if (status === 200 || status === 202) {
|
|
@@ -685,6 +698,10 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
685
698
|
} else if (isPermanent(status)) {
|
|
686
699
|
stats.batchesRejected++;
|
|
687
700
|
stats.lastError = `rejected ${status}, batch ${batch.id} dropped`;
|
|
701
|
+
if (batch.token) {
|
|
702
|
+
refusedRunBatches++;
|
|
703
|
+
lastRefusedStatus = status;
|
|
704
|
+
}
|
|
688
705
|
retryBatch = null;
|
|
689
706
|
retryAt = null;
|
|
690
707
|
attempts = 0;
|
|
@@ -762,11 +779,23 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
762
779
|
resume();
|
|
763
780
|
const sentBefore = stats.batchesSent;
|
|
764
781
|
const linesBefore = stats.linesSent;
|
|
782
|
+
const refusedBefore = refusedRunBatches;
|
|
765
783
|
let failures = 0;
|
|
766
784
|
flushLimit = deadline;
|
|
767
785
|
try {
|
|
768
786
|
if (inflight) {
|
|
769
|
-
|
|
787
|
+
if (deadline === null) {
|
|
788
|
+
await inflight;
|
|
789
|
+
} else {
|
|
790
|
+
const bound = timeoutAfter(Math.max(0, deadline - deps.now()));
|
|
791
|
+
try {
|
|
792
|
+
await Promise.race([inflight, bound.promise]);
|
|
793
|
+
} catch {
|
|
794
|
+
// the deadline passed first; the loop below starts nothing more
|
|
795
|
+
} finally {
|
|
796
|
+
bound.clear();
|
|
797
|
+
}
|
|
798
|
+
}
|
|
770
799
|
}
|
|
771
800
|
while (
|
|
772
801
|
(queue.length > 0 || retryBatch) &&
|
|
@@ -786,15 +815,21 @@ export const createPushClient = (deps: PushDeps) => {
|
|
|
786
815
|
}
|
|
787
816
|
}
|
|
788
817
|
}
|
|
789
|
-
const
|
|
790
|
-
if (
|
|
818
|
+
const sentAll = queue.length === 0 && !retryBatch;
|
|
819
|
+
if (sentAll) {
|
|
791
820
|
await sendClock(deadline);
|
|
792
821
|
}
|
|
822
|
+
const refused = refusedRunBatches - refusedBefore;
|
|
823
|
+
const ok = sentAll && refused === 0;
|
|
793
824
|
return {
|
|
794
825
|
ok,
|
|
795
826
|
batches: stats.batchesSent - sentBefore,
|
|
796
827
|
lines: stats.linesSent - linesBefore,
|
|
797
|
-
error: ok
|
|
828
|
+
error: ok
|
|
829
|
+
? null
|
|
830
|
+
: !sentAll
|
|
831
|
+
? (stats.lastError ?? 'flush deadline')
|
|
832
|
+
: `rejected ${lastRefusedStatus}: ${refused} ${refused === 1 ? 'batch' : 'batches'} of the run dropped`,
|
|
798
833
|
};
|
|
799
834
|
} finally {
|
|
800
835
|
flushLimit = null;
|
package/metro.js
CHANGED
|
@@ -195,6 +195,8 @@ function withKeepSnappy(input) {
|
|
|
195
195
|
);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
process.env.KEEPSNAPPY_SDK_VERSION = require('./js/build-version.js').sdkBuildVersion();
|
|
199
|
+
|
|
198
200
|
// Transform workers are forked after the config loads and inherit this env.
|
|
199
201
|
process.env[ENV_UPSTREAM] = upstream;
|
|
200
202
|
process.env[ENV_ENTRY] = entry;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepsnappy/react-native",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.15",
|
|
4
4
|
"description": "KeepSnappy in-app SDK for React Native / Expo: zero-code measurement windows, React commit hook, native signposts and log file",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|