@logbrew/react-native 0.1.1 → 0.1.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.
@@ -0,0 +1,531 @@
1
+ "use strict";
2
+
3
+ const FATAL_RECORD_SCHEMA_VERSION = 1;
4
+ const MAX_ADMITTED_FATAL_IDS = 256;
5
+ const MAX_FATAL_FILENAME_BYTES = 512;
6
+ const MAX_FATAL_STACK_BYTES = 16 * 1024;
7
+ const MAX_FATAL_STACK_FRAMES = 24;
8
+
9
+ const admittedFatalIdsByClient = new WeakMap();
10
+ let nextFatalSequence = 0;
11
+
12
+ function createFatalController({
13
+ client,
14
+ eventMessage,
15
+ fatalStore,
16
+ issue,
17
+ onDiagnostic,
18
+ sanitizers
19
+ }) {
20
+ const methods = fatalStoreMethods(fatalStore);
21
+ const state = {
22
+ acknowledgedRecords: 0,
23
+ available: methods !== undefined,
24
+ corruptRecords: 0,
25
+ droppedRecords: 0,
26
+ lastOutcome: methods ? "idle" : "unavailable",
27
+ replayedRecords: 0,
28
+ storedRecords: 0
29
+ };
30
+
31
+ return {
32
+ available: state.available,
33
+ discard() {
34
+ return discardPendingFatal(fatalStore, methods, state, onDiagnostic);
35
+ },
36
+ health() {
37
+ return fatalHealthSnapshot(state);
38
+ },
39
+ replay() {
40
+ replayPendingFatal({
41
+ client,
42
+ eventMessage,
43
+ fatalStore,
44
+ issue,
45
+ methods,
46
+ onDiagnostic,
47
+ sanitizers,
48
+ state
49
+ });
50
+ },
51
+ store(error) {
52
+ return storeFatalError({
53
+ error,
54
+ fatalStore,
55
+ methods,
56
+ onDiagnostic,
57
+ sanitizers,
58
+ state
59
+ });
60
+ }
61
+ };
62
+ }
63
+
64
+ function fatalStoreMethods(fatalStore) {
65
+ if (!isObjectLike(fatalStore)) {
66
+ return undefined;
67
+ }
68
+ const methods = {
69
+ acknowledge: safeFunction(fatalStore, "acknowledgeFatalRecord"),
70
+ discard: safeFunction(fatalStore, "discardFatalRecord"),
71
+ read: safeFunction(fatalStore, "readFatalRecord"),
72
+ write: safeFunction(fatalStore, "writeFatalRecord")
73
+ };
74
+ return Object.values(methods).every((method) => typeof method === "function")
75
+ ? methods
76
+ : undefined;
77
+ }
78
+
79
+ function discardPendingFatal(fatalStore, methods, state, onDiagnostic) {
80
+ if (!methods) {
81
+ return false;
82
+ }
83
+ let result;
84
+ try {
85
+ result = methods.discard.call(fatalStore);
86
+ } catch {
87
+ state.lastOutcome = "discard_failed";
88
+ emitDiagnostic(onDiagnostic, "fatal_discard_failed");
89
+ return false;
90
+ }
91
+ if (resultStatus(result) !== "discarded") {
92
+ state.lastOutcome = resultStatus(result) === "empty" ? "empty" : "discard_failed";
93
+ if (state.lastOutcome === "discard_failed") {
94
+ emitDiagnostic(onDiagnostic, "fatal_discard_failed");
95
+ }
96
+ return false;
97
+ }
98
+ state.lastOutcome = "discarded";
99
+ return true;
100
+ }
101
+
102
+ function storeFatalError({
103
+ error,
104
+ fatalStore,
105
+ methods,
106
+ onDiagnostic,
107
+ sanitizers,
108
+ state
109
+ }) {
110
+ if (!methods) {
111
+ return false;
112
+ }
113
+ let result;
114
+ try {
115
+ result = methods.write.call(fatalStore, createFatalRecord(error, sanitizers));
116
+ } catch {
117
+ result = undefined;
118
+ }
119
+ const status = resultStatus(result);
120
+ if (status === "stored" || status === "stored_after_corruption") {
121
+ state.storedRecords = incrementBounded(state.storedRecords);
122
+ state.corruptRecords = boundedResultCounter(result, "corruptRecords", state.corruptRecords);
123
+ state.lastOutcome = status;
124
+ return true;
125
+ }
126
+ if (status === "dropped_pending") {
127
+ state.droppedRecords = boundedResultCounter(
128
+ result,
129
+ "droppedRecords",
130
+ incrementBounded(state.droppedRecords)
131
+ );
132
+ state.lastOutcome = status;
133
+ emitDiagnostic(onDiagnostic, "fatal_record_dropped");
134
+ return false;
135
+ }
136
+ state.lastOutcome = "storage_error";
137
+ emitDiagnostic(onDiagnostic, "fatal_store_failed");
138
+ return false;
139
+ }
140
+
141
+ function replayPendingFatal({
142
+ client,
143
+ eventMessage,
144
+ fatalStore,
145
+ issue,
146
+ methods,
147
+ onDiagnostic,
148
+ sanitizers,
149
+ state
150
+ }) {
151
+ if (!methods) {
152
+ return;
153
+ }
154
+ let result;
155
+ try {
156
+ result = methods.read.call(fatalStore);
157
+ } catch {
158
+ result = undefined;
159
+ }
160
+ const status = resultStatus(result);
161
+ if (status === "empty") {
162
+ state.lastOutcome = "empty";
163
+ return;
164
+ }
165
+ if (status === "corrupt_discarded") {
166
+ state.corruptRecords = boundedResultCounter(
167
+ result,
168
+ "corruptRecords",
169
+ incrementBounded(state.corruptRecords)
170
+ );
171
+ state.lastOutcome = status;
172
+ emitDiagnostic(onDiagnostic, "fatal_corrupt_record_discarded");
173
+ return;
174
+ }
175
+ const record = status === "pending"
176
+ ? validatedFatalRecord(safeReadProperty(result, "record"), sanitizers)
177
+ : undefined;
178
+ if (!record) {
179
+ state.lastOutcome = "replay_failed";
180
+ emitDiagnostic(onDiagnostic, "fatal_replay_failed");
181
+ return;
182
+ }
183
+
184
+ state.corruptRecords = Math.max(state.corruptRecords, record.corruptRecords);
185
+ state.droppedRecords = Math.max(state.droppedRecords, record.droppedRecords);
186
+ if (wasAdmittedInRuntime(client, record.id)) {
187
+ acknowledgeFatalRecord(fatalStore, methods, record.id, state, onDiagnostic);
188
+ return;
189
+ }
190
+
191
+ const before = admissionSnapshot(client);
192
+ try {
193
+ issue.call(client, record.id, record.timestamp, fatalEventAttributes(record, eventMessage));
194
+ } catch {
195
+ state.lastOutcome = "replay_failed";
196
+ emitDiagnostic(onDiagnostic, "fatal_replay_failed");
197
+ return;
198
+ }
199
+ const after = admissionSnapshot(client);
200
+ if (!wasAdmitted(before, after)) {
201
+ state.lastOutcome = "replay_not_admitted";
202
+ emitDiagnostic(onDiagnostic, "fatal_replay_not_admitted");
203
+ return;
204
+ }
205
+
206
+ rememberAdmittedFatalId(client, record.id);
207
+ state.replayedRecords = incrementBounded(state.replayedRecords);
208
+ acknowledgeFatalRecord(fatalStore, methods, record.id, state, onDiagnostic);
209
+ }
210
+
211
+ function acknowledgeFatalRecord(fatalStore, methods, recordId, state, onDiagnostic) {
212
+ let result;
213
+ try {
214
+ result = methods.acknowledge.call(fatalStore, recordId);
215
+ } catch {
216
+ result = undefined;
217
+ }
218
+ if (resultStatus(result) === "acknowledged"
219
+ && safeReadProperty(result, "recordId") === recordId) {
220
+ state.acknowledgedRecords = incrementBounded(state.acknowledgedRecords);
221
+ state.lastOutcome = "acknowledged";
222
+ return;
223
+ }
224
+ state.lastOutcome = "acknowledge_failed";
225
+ emitDiagnostic(onDiagnostic, "fatal_acknowledge_failed");
226
+ }
227
+
228
+ function admissionSnapshot(client) {
229
+ const pendingEvents = safeFunction(client, "pendingEvents");
230
+ const droppedEvents = safeFunction(client, "droppedEvents");
231
+ if (!pendingEvents || !droppedEvents) {
232
+ return undefined;
233
+ }
234
+ try {
235
+ const pending = pendingEvents.call(client);
236
+ const dropped = droppedEvents.call(client);
237
+ return isBoundedCounter(pending) && isBoundedCounter(dropped)
238
+ ? { dropped, pending }
239
+ : undefined;
240
+ } catch {
241
+ return undefined;
242
+ }
243
+ }
244
+
245
+ function wasAdmitted(before, after) {
246
+ return before !== undefined
247
+ && after !== undefined
248
+ && after.pending === before.pending + 1
249
+ && after.dropped === before.dropped;
250
+ }
251
+
252
+ function wasAdmittedInRuntime(client, recordId) {
253
+ return isObjectLike(client) && admittedFatalIdsByClient.get(client)?.has(recordId) === true;
254
+ }
255
+
256
+ function rememberAdmittedFatalId(client, recordId) {
257
+ if (!isObjectLike(client)) {
258
+ return;
259
+ }
260
+ let admittedIds = admittedFatalIdsByClient.get(client);
261
+ if (!admittedIds) {
262
+ admittedIds = new Set();
263
+ admittedFatalIdsByClient.set(client, admittedIds);
264
+ }
265
+ if (admittedIds.has(recordId)) {
266
+ return;
267
+ }
268
+ admittedIds.add(recordId);
269
+ if (admittedIds.size > MAX_ADMITTED_FATAL_IDS) {
270
+ admittedIds.delete(admittedIds.values().next().value);
271
+ }
272
+ }
273
+
274
+ function createFatalRecord(error, sanitizers) {
275
+ return Object.freeze({
276
+ corruptRecords: 0,
277
+ droppedRecords: 0,
278
+ errorName: sanitizers.errorName(error),
279
+ id: nextFatalEventId(),
280
+ schemaVersion: FATAL_RECORD_SCHEMA_VERSION,
281
+ stackFrames: fatalStackFrames(error, sanitizers.stackFrame),
282
+ timestamp: new Date().toISOString()
283
+ });
284
+ }
285
+
286
+ function fatalStackFrames(error, stackFrame) {
287
+ const stack = safeReadProperty(error, "stack");
288
+ const candidate = typeof stack === "string"
289
+ ? stack.slice(0, MAX_FATAL_STACK_BYTES)
290
+ : "";
291
+ const frames = [];
292
+ for (const line of candidate.split(/\r?\n/u)) {
293
+ const frame = stackFrame(line);
294
+ const filename = frame ? normalizedStoredFatalFilename(frame.filename) : undefined;
295
+ if (filename && validFatalFilename(filename)) {
296
+ frames.push(Object.freeze({ ...frame, filename }));
297
+ if (frames.length === MAX_FATAL_STACK_FRAMES) {
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ return Object.freeze(frames);
303
+ }
304
+
305
+ function fatalEventAttributes(record, eventMessage) {
306
+ return Object.freeze({
307
+ level: "fatal",
308
+ message: eventMessage,
309
+ metadata: Object.freeze({
310
+ automatic: true,
311
+ fatal: true,
312
+ handled: false,
313
+ mechanism: "react_native_error_utils",
314
+ nativeCorruptRecords: record.corruptRecords,
315
+ nativeDroppedRecords: record.droppedRecords,
316
+ replayed: true,
317
+ source: "react-native.global_error"
318
+ }),
319
+ stackFrames: record.stackFrames.map((frame) => Object.freeze({ ...frame })),
320
+ title: eventMessage
321
+ });
322
+ }
323
+
324
+ function validatedFatalRecord(value, sanitizers) {
325
+ try {
326
+ if (!isPlainObject(value)
327
+ || !hasExactKeys(value, [
328
+ "corruptRecords",
329
+ "droppedRecords",
330
+ "errorName",
331
+ "id",
332
+ "schemaVersion",
333
+ "stackFrames",
334
+ "timestamp"
335
+ ])
336
+ || value.schemaVersion !== FATAL_RECORD_SCHEMA_VERSION
337
+ || typeof value.id !== "string"
338
+ || !/^evt_rn_fatal_[a-z0-9]+(?:_[a-z0-9]+)*$/u.test(value.id)
339
+ || !sanitizers.isErrorName(value.errorName)
340
+ || !isBoundedCounter(value.corruptRecords)
341
+ || !isBoundedCounter(value.droppedRecords)
342
+ || !validFatalTimestamp(value.timestamp)
343
+ || !Array.isArray(value.stackFrames)
344
+ || value.stackFrames.length > MAX_FATAL_STACK_FRAMES) {
345
+ return undefined;
346
+ }
347
+ const frames = value.stackFrames.map(validatedFatalFrame);
348
+ if (frames.some((frame) => frame === undefined)) {
349
+ return undefined;
350
+ }
351
+ return {
352
+ corruptRecords: value.corruptRecords,
353
+ droppedRecords: value.droppedRecords,
354
+ errorName: value.errorName,
355
+ id: value.id,
356
+ schemaVersion: FATAL_RECORD_SCHEMA_VERSION,
357
+ stackFrames: frames,
358
+ timestamp: value.timestamp
359
+ };
360
+ } catch {
361
+ return undefined;
362
+ }
363
+ }
364
+
365
+ function validatedFatalFrame(value) {
366
+ try {
367
+ if (!isPlainObject(value)
368
+ || !hasExactKeys(value, ["column", "filename", "line"])
369
+ || !validFatalFilename(value.filename)
370
+ || positiveInteger(value.line) === undefined
371
+ || positiveInteger(value.column) === undefined) {
372
+ return undefined;
373
+ }
374
+ return {
375
+ column: value.column,
376
+ filename: value.filename,
377
+ line: value.line
378
+ };
379
+ } catch {
380
+ return undefined;
381
+ }
382
+ }
383
+
384
+ function validFatalFilename(value) {
385
+ return typeof value === "string"
386
+ && value.length > 0
387
+ && utf8ByteLength(value) <= MAX_FATAL_FILENAME_BYTES
388
+ && !hasControlCharacter(value)
389
+ && !value.startsWith("/")
390
+ && !/^[A-Za-z]:/u.test(value)
391
+ && !value.includes("\\")
392
+ && !value.includes("://")
393
+ && !value.includes("?")
394
+ && !value.includes("#")
395
+ && !value.split("/").includes("..");
396
+ }
397
+
398
+ function normalizedStoredFatalFilename(value) {
399
+ return typeof value === "string"
400
+ && value.startsWith("/")
401
+ && value.length > 1
402
+ && value.indexOf("/", 1) === -1
403
+ ? value.slice(1)
404
+ : value;
405
+ }
406
+
407
+ function validFatalTimestamp(value) {
408
+ return typeof value === "string"
409
+ && value.length >= 20
410
+ && value.length <= 35
411
+ && /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z$/u.test(value);
412
+ }
413
+
414
+ function resultStatus(result) {
415
+ try {
416
+ return isPlainObject(result) && typeof result.status === "string"
417
+ ? result.status
418
+ : undefined;
419
+ } catch {
420
+ return undefined;
421
+ }
422
+ }
423
+
424
+ function boundedResultCounter(result, property, fallback) {
425
+ const value = isPlainObject(result) ? safeReadProperty(result, property) : undefined;
426
+ return isBoundedCounter(value) ? value : fallback;
427
+ }
428
+
429
+ function hasExactKeys(value, expected) {
430
+ try {
431
+ const keys = Object.keys(value).sort();
432
+ return keys.length === expected.length
433
+ && keys.every((key, index) => key === expected[index]);
434
+ } catch {
435
+ return false;
436
+ }
437
+ }
438
+
439
+ function isBoundedCounter(value) {
440
+ return Number.isSafeInteger(value) && value >= 0;
441
+ }
442
+
443
+ function isPlainObject(value) {
444
+ if (value === null || typeof value !== "object") {
445
+ return false;
446
+ }
447
+ try {
448
+ const prototype = Object.getPrototypeOf(value);
449
+ return prototype === Object.prototype || prototype === null;
450
+ } catch {
451
+ return false;
452
+ }
453
+ }
454
+
455
+ function safeReadProperty(value, property) {
456
+ if (!isObjectLike(value)) {
457
+ return undefined;
458
+ }
459
+ try {
460
+ return value[property];
461
+ } catch {
462
+ return undefined;
463
+ }
464
+ }
465
+
466
+ function safeFunction(value, property) {
467
+ const candidate = safeReadProperty(value, property);
468
+ return typeof candidate === "function" ? candidate : undefined;
469
+ }
470
+
471
+ function hasControlCharacter(value) {
472
+ return Array.from(value).some((character) => {
473
+ const code = character.codePointAt(0);
474
+ return code !== undefined && (code <= 31 || code === 127);
475
+ });
476
+ }
477
+
478
+ function positiveInteger(value) {
479
+ if (!/^[1-9][0-9]*$/u.test(String(value))) {
480
+ return undefined;
481
+ }
482
+ const number = Number(value);
483
+ return Number.isSafeInteger(number) && number <= 2147483647 ? number : undefined;
484
+ }
485
+
486
+ function nextFatalEventId() {
487
+ nextFatalSequence = incrementBounded(nextFatalSequence);
488
+ return `evt_rn_fatal_${Date.now().toString(36)}_${nextFatalSequence.toString(36)}`;
489
+ }
490
+
491
+ function incrementBounded(value) {
492
+ return value >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : value + 1;
493
+ }
494
+
495
+ function emitDiagnostic(onDiagnostic, code) {
496
+ if (typeof onDiagnostic !== "function") {
497
+ return;
498
+ }
499
+ try {
500
+ onDiagnostic(Object.freeze({ code }));
501
+ } catch {
502
+ // Diagnostics must never interfere with application error handling.
503
+ }
504
+ }
505
+
506
+ function utf8ByteLength(value) {
507
+ let bytes = 0;
508
+ for (const character of value) {
509
+ const code = character.codePointAt(0);
510
+ bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
511
+ }
512
+ return bytes;
513
+ }
514
+
515
+ function fatalHealthSnapshot(state) {
516
+ return Object.freeze({
517
+ acknowledgedRecords: state.acknowledgedRecords,
518
+ available: state.available,
519
+ corruptRecords: state.corruptRecords,
520
+ droppedRecords: state.droppedRecords,
521
+ lastOutcome: state.lastOutcome,
522
+ replayedRecords: state.replayedRecords,
523
+ storedRecords: state.storedRecords
524
+ });
525
+ }
526
+
527
+ function isObjectLike(value) {
528
+ return value !== null && (typeof value === "object" || typeof value === "function");
529
+ }
530
+
531
+ module.exports = { createFatalController };