@logbrew/react-native 0.1.2 → 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 };
package/global-errors.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const { createReactNativeErrorEvent } = require("./index.cjs");
4
+ const { createFatalController } = require("./fatal-replay.cjs");
4
5
 
5
6
  const AUTOMATIC_ERROR_MESSAGE = "React Native global JavaScript report";
6
7
  const MAX_STACK_BYTES = 16 * 1024;
@@ -14,12 +15,18 @@ const SAFE_ERROR_NAMES = new Set([
14
15
  "TypeError",
15
16
  "URIError"
16
17
  ]);
18
+ const FATAL_SANITIZERS = Object.freeze({
19
+ errorName: safeErrorName,
20
+ isErrorName: (value) => SAFE_ERROR_NAMES.has(value),
21
+ stackFrame: safeStackFrameParts
22
+ });
17
23
  const installations = new WeakMap();
18
24
  let nextEventSequence = 0;
19
25
 
20
26
  function installLogBrewReactNativeGlobalErrorHandler({
21
27
  client,
22
28
  errorUtils = globalThis?.ErrorUtils,
29
+ fatalStore,
23
30
  onDiagnostic
24
31
  } = {}) {
25
32
  const issue = safeFunction(client, "issue");
@@ -54,6 +61,14 @@ function installLogBrewReactNativeGlobalErrorHandler({
54
61
  lastOutcome: "idle",
55
62
  suppressedEvents: 0
56
63
  };
64
+ const fatal = createFatalController({
65
+ client,
66
+ eventMessage: AUTOMATIC_ERROR_MESSAGE,
67
+ fatalStore,
68
+ issue,
69
+ onDiagnostic,
70
+ sanitizers: FATAL_SANITIZERS
71
+ });
57
72
  const capturedErrors = new WeakSet();
58
73
  const handler = (error, isFatal) => {
59
74
  if (!state.active) {
@@ -67,8 +82,12 @@ function installLogBrewReactNativeGlobalErrorHandler({
67
82
  state.handling = true;
68
83
  try {
69
84
  if (isFatal === true) {
70
- state.lastOutcome = "fatal_unsupported";
71
- emitDiagnostic(onDiagnostic, "fatal_capture_requires_sync_store");
85
+ if (fatal.available) {
86
+ state.lastOutcome = fatal.store(error) ? "fatal_stored" : "fatal_store_failed";
87
+ } else {
88
+ state.lastOutcome = "fatal_unsupported";
89
+ emitDiagnostic(onDiagnostic, "fatal_capture_requires_sync_store");
90
+ }
72
91
  } else if (isObjectLike(error) && capturedErrors.has(error)) {
73
92
  recordSuppression(state, onDiagnostic, "duplicate_capture_suppressed");
74
93
  } else {
@@ -86,6 +105,12 @@ function installLogBrewReactNativeGlobalErrorHandler({
86
105
  };
87
106
 
88
107
  const installation = Object.freeze({
108
+ discardPendingFatalRecord() {
109
+ return fatal.discard();
110
+ },
111
+ fatalHealth() {
112
+ return fatal.health();
113
+ },
89
114
  health() {
90
115
  return healthSnapshot(state);
91
116
  },
@@ -133,6 +158,7 @@ function installLogBrewReactNativeGlobalErrorHandler({
133
158
  return inactiveInstallation();
134
159
  }
135
160
  installations.set(errorUtils, installation);
161
+ fatal.replay();
136
162
  return installation;
137
163
  }
138
164
 
@@ -207,6 +233,13 @@ function safeStack(error) {
207
233
  }
208
234
 
209
235
  function safeStackFrame(line) {
236
+ const frame = safeStackFrameParts(line);
237
+ return frame
238
+ ? `${frame.filename}:${frame.line}:${frame.column}`
239
+ : undefined;
240
+ }
241
+
242
+ function safeStackFrameParts(line) {
210
243
  let location = typeof line === "string" ? line.trim() : "";
211
244
  if (!location) {
212
245
  return undefined;
@@ -229,7 +262,11 @@ function safeStackFrame(line) {
229
262
  if (!filename || lineNumber === undefined || column === undefined) {
230
263
  return undefined;
231
264
  }
232
- return `${filename}:${lineNumber}:${column}`;
265
+ return {
266
+ column,
267
+ filename,
268
+ line: lineNumber
269
+ };
233
270
  }
234
271
 
235
272
  function safeStackFilename(value) {
@@ -340,7 +377,18 @@ function inactiveInstallation() {
340
377
  lastOutcome: "unavailable",
341
378
  suppressedEvents: 0
342
379
  });
380
+ const fatalSnapshot = Object.freeze({
381
+ acknowledgedRecords: 0,
382
+ available: false,
383
+ corruptRecords: 0,
384
+ droppedRecords: 0,
385
+ lastOutcome: "unavailable",
386
+ replayedRecords: 0,
387
+ storedRecords: 0
388
+ });
343
389
  return Object.freeze({
390
+ discardPendingFatalRecord: () => false,
391
+ fatalHealth: () => fatalSnapshot,
344
392
  health: () => snapshot,
345
393
  remove: () => false
346
394
  });