@goliapkg/sentori-react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +5 -0
  2. package/SentoriReactNative.podspec +21 -0
  3. package/android/build.gradle +38 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +213 -0
  6. package/android/src/main/java/com/sentori/SentoriModule.kt +39 -0
  7. package/android/src/test/java/com/sentori/SentoriCrashHandlerTest.kt +60 -0
  8. package/expo-module.config.json +9 -0
  9. package/ios/SentoriCrashHandler.swift +160 -0
  10. package/ios/SentoriModule.swift +43 -0
  11. package/ios/Tests/SentoriCrashHandlerTests.swift +59 -0
  12. package/lib/breadcrumbs.d.ts +11 -0
  13. package/lib/breadcrumbs.d.ts.map +1 -0
  14. package/lib/breadcrumbs.js +21 -0
  15. package/lib/breadcrumbs.js.map +1 -0
  16. package/lib/capture.d.ts +23 -0
  17. package/lib/capture.d.ts.map +1 -0
  18. package/lib/capture.js +91 -0
  19. package/lib/capture.js.map +1 -0
  20. package/lib/config.d.ts +12 -0
  21. package/lib/config.d.ts.map +1 -0
  22. package/lib/config.js +10 -0
  23. package/lib/config.js.map +1 -0
  24. package/lib/error-boundary.d.ts +17 -0
  25. package/lib/error-boundary.d.ts.map +1 -0
  26. package/lib/error-boundary.js +26 -0
  27. package/lib/error-boundary.js.map +1 -0
  28. package/lib/handlers/global.d.ts +2 -0
  29. package/lib/handlers/global.d.ts.map +1 -0
  30. package/lib/handlers/global.js +29 -0
  31. package/lib/handlers/global.js.map +1 -0
  32. package/lib/handlers/network.d.ts +2 -0
  33. package/lib/handlers/network.d.ts.map +1 -0
  34. package/lib/handlers/network.js +69 -0
  35. package/lib/handlers/network.js.map +1 -0
  36. package/lib/handlers/promise.d.ts +2 -0
  37. package/lib/handlers/promise.d.ts.map +1 -0
  38. package/lib/handlers/promise.js +27 -0
  39. package/lib/handlers/promise.js.map +1 -0
  40. package/lib/index.d.ts +18 -0
  41. package/lib/index.d.ts.map +1 -0
  42. package/lib/index.js +20 -0
  43. package/lib/index.js.map +1 -0
  44. package/lib/init.d.ts +18 -0
  45. package/lib/init.d.ts.map +1 -0
  46. package/lib/init.js +56 -0
  47. package/lib/init.js.map +1 -0
  48. package/lib/native.d.ts +23 -0
  49. package/lib/native.d.ts.map +1 -0
  50. package/lib/native.js +56 -0
  51. package/lib/native.js.map +1 -0
  52. package/lib/stack.d.ts +3 -0
  53. package/lib/stack.d.ts.map +1 -0
  54. package/lib/stack.js +69 -0
  55. package/lib/stack.js.map +1 -0
  56. package/lib/transport.d.ts +8 -0
  57. package/lib/transport.d.ts.map +1 -0
  58. package/lib/transport.js +143 -0
  59. package/lib/transport.js.map +1 -0
  60. package/lib/types.d.ts +62 -0
  61. package/lib/types.d.ts.map +1 -0
  62. package/lib/types.js +2 -0
  63. package/lib/types.js.map +1 -0
  64. package/lib/uuid.d.ts +11 -0
  65. package/lib/uuid.d.ts.map +1 -0
  66. package/lib/uuid.js +46 -0
  67. package/lib/uuid.js.map +1 -0
  68. package/package.json +66 -0
  69. package/src/__tests__/breadcrumbs.test.ts +44 -0
  70. package/src/__tests__/stack.test.ts +43 -0
  71. package/src/__tests__/transport.test.ts +112 -0
  72. package/src/__tests__/uuid.test.ts +41 -0
  73. package/src/breadcrumbs.ts +33 -0
  74. package/src/capture.ts +108 -0
  75. package/src/config.ts +21 -0
  76. package/src/error-boundary.tsx +38 -0
  77. package/src/handlers/global.ts +36 -0
  78. package/src/handlers/network.ts +70 -0
  79. package/src/handlers/promise.ts +38 -0
  80. package/src/index.ts +37 -0
  81. package/src/init.ts +80 -0
  82. package/src/native.ts +71 -0
  83. package/src/stack.ts +72 -0
  84. package/src/transport.ts +164 -0
  85. package/src/types.ts +63 -0
  86. package/src/uuid.ts +56 -0
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+
3
+ import { uuidV7 } from '../uuid';
4
+
5
+ describe('uuidV7', () => {
6
+ it('produces a 36-char hyphenated UUID with version 7', () => {
7
+ const u = uuidV7();
8
+ expect(u).toMatch(
9
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
10
+ );
11
+ });
12
+
13
+ it('encodes the current ms timestamp in the leading 48 bits', () => {
14
+ const before = Date.now();
15
+ const u = uuidV7();
16
+ const after = Date.now();
17
+ const tsHex = u.replace(/-/g, '').slice(0, 12);
18
+ const ts = parseInt(tsHex, 16);
19
+ expect(ts).toBeGreaterThanOrEqual(before);
20
+ expect(ts).toBeLessThanOrEqual(after);
21
+ });
22
+
23
+ it('produces unique values across rapid calls', () => {
24
+ const seen = new Set<string>();
25
+ for (let i = 0; i < 1000; i++) seen.add(uuidV7());
26
+ expect(seen.size).toBe(1000);
27
+ });
28
+
29
+ it('always sets version 7 nibble', () => {
30
+ for (let i = 0; i < 100; i++) {
31
+ expect(uuidV7().charAt(14)).toBe('7');
32
+ }
33
+ });
34
+
35
+ it('always sets variant to 10xx', () => {
36
+ for (let i = 0; i < 100; i++) {
37
+ const ch = uuidV7().charAt(19).toLowerCase();
38
+ expect('89ab'.includes(ch)).toBe(true);
39
+ }
40
+ });
41
+ });
@@ -0,0 +1,33 @@
1
+ import type { Breadcrumb, BreadcrumbType } from './types';
2
+
3
+ const MAX_BREADCRUMBS = 100;
4
+
5
+ let _buffer: Breadcrumb[] = [];
6
+
7
+ export type AddBreadcrumbInput = {
8
+ type: BreadcrumbType;
9
+ data: Record<string, unknown>;
10
+ timestamp?: string;
11
+ };
12
+
13
+ export const addBreadcrumb = (input: AddBreadcrumbInput): void => {
14
+ const crumb: Breadcrumb = {
15
+ timestamp: input.timestamp ?? new Date().toISOString(),
16
+ type: input.type,
17
+ data: input.data,
18
+ };
19
+ _buffer.push(crumb);
20
+ if (_buffer.length > MAX_BREADCRUMBS) {
21
+ _buffer.shift();
22
+ }
23
+ };
24
+
25
+ export const getBreadcrumbs = (): Breadcrumb[] => [..._buffer];
26
+
27
+ export const clearBreadcrumbs = (): void => {
28
+ _buffer = [];
29
+ };
30
+
31
+ export const __resetForTests = (): void => {
32
+ _buffer = [];
33
+ };
package/src/capture.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { getConfig, isInitialized } from './config';
2
+ import { getBreadcrumbs } from './breadcrumbs';
3
+ import { parseStack } from './stack';
4
+ import { enqueue } from './transport';
5
+ import { uuidV7 } from './uuid';
6
+ import type { App, Device, Event, SentoriError, Tags, User } from './types';
7
+
8
+ let _user: User | null = null;
9
+
10
+ /**
11
+ * Attach a stable user identifier to events captured after this call.
12
+ *
13
+ * PII policy (Phase 16 sub-D): the User shape is intentionally limited
14
+ * to `{ id?, anonymous? }` — no email, name, IP, or other identifying
15
+ * fields. Use a hashed / pseudonymous id (e.g. uuid v4 stored in
16
+ * AsyncStorage on first launch). The server schema enforces the same
17
+ * shape, so any extra fields you tack on at the JS layer would be
18
+ * rejected with `validationFailed` and never persisted.
19
+ *
20
+ * Pass `null` to clear (e.g. on sign-out).
21
+ */
22
+ export const setUser = (user: User | null): void => {
23
+ _user = user;
24
+ };
25
+
26
+ export const getUser = (): User | null => _user;
27
+
28
+ export type CaptureExtras = {
29
+ tags?: Tags;
30
+ user?: User;
31
+ fingerprint?: string[];
32
+ };
33
+
34
+ export const captureError = (error: Error, extras?: CaptureExtras): void => {
35
+ if (!isInitialized()) return;
36
+ const config = getConfig();
37
+ if (!config) return;
38
+
39
+ const event: Event = {
40
+ id: uuidV7(),
41
+ timestamp: new Date().toISOString(),
42
+ kind: 'error',
43
+ platform: 'javascript',
44
+ release: config.release,
45
+ environment: config.environment,
46
+ device: collectDevice(),
47
+ app: collectApp(config.release),
48
+ user: extras?.user ?? _user,
49
+ tags: extras?.tags,
50
+ breadcrumbs: getBreadcrumbs(),
51
+ error: errorToObject(error),
52
+ fingerprint: extras?.fingerprint,
53
+ };
54
+
55
+ enqueue(event);
56
+ };
57
+
58
+ export const captureException = captureError;
59
+
60
+ const errorToObject = (error: Error): SentoriError => {
61
+ const causeRaw = (error as { cause?: unknown }).cause;
62
+ let cause: SentoriError | null = null;
63
+ if (causeRaw instanceof Error) {
64
+ cause = errorToObject(causeRaw);
65
+ }
66
+
67
+ return {
68
+ type: error.name || 'Error',
69
+ message: error.message,
70
+ stack: parseStack(error.stack),
71
+ cause,
72
+ };
73
+ };
74
+
75
+ const collectDevice = (): Device => {
76
+ let os: Device['os'] = 'other';
77
+ let osVersion = '0';
78
+ try {
79
+ const RN = require('react-native') as {
80
+ Platform: { OS: string; Version: string | number };
81
+ };
82
+ const rnOS = RN.Platform.OS;
83
+ os = rnOS === 'ios' || rnOS === 'android' || rnOS === 'web' ? rnOS : 'other';
84
+ osVersion = String(RN.Platform.Version);
85
+ } catch {
86
+ // not in RN runtime (jest, bun test)
87
+ }
88
+ return { os, osVersion };
89
+ };
90
+
91
+ const collectApp = (release: string): App => {
92
+ const m = /^(?:[^@]+@)?([^+]+)(?:\+(.+))?$/.exec(release);
93
+ const version = m?.[1] ?? '0.0.0';
94
+ const build = m?.[2];
95
+
96
+ let rnVersion = 'unknown';
97
+ try {
98
+ rnVersion = (require('react-native/package.json') as { version: string }).version;
99
+ } catch {
100
+ // not in RN runtime
101
+ }
102
+
103
+ return {
104
+ version,
105
+ build,
106
+ framework: { name: 'react-native', version: rnVersion },
107
+ };
108
+ };
package/src/config.ts ADDED
@@ -0,0 +1,21 @@
1
+ export type Config = {
2
+ token: string;
3
+ release: string;
4
+ environment: string;
5
+ ingestUrl: string;
6
+ enabled: boolean;
7
+ };
8
+
9
+ let _config: Config | null = null;
10
+
11
+ export const setConfig = (config: Config): void => {
12
+ _config = config;
13
+ };
14
+
15
+ export const getConfig = (): Config | null => _config;
16
+
17
+ export const isInitialized = (): boolean => _config !== null;
18
+
19
+ export const __resetForTests = (): void => {
20
+ _config = null;
21
+ };
@@ -0,0 +1,38 @@
1
+ import React, { Component, type ErrorInfo, type ReactNode } from 'react';
2
+
3
+ import { captureError } from './capture';
4
+
5
+ export type ErrorBoundaryProps = {
6
+ fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
7
+ children: ReactNode;
8
+ };
9
+
10
+ type State = { error: Error | null };
11
+
12
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
13
+ state: State = { error: null };
14
+
15
+ static getDerivedStateFromError(error: Error): State {
16
+ return { error };
17
+ }
18
+
19
+ componentDidCatch(error: Error, _info: ErrorInfo): void {
20
+ captureError(error);
21
+ }
22
+
23
+ reset = (): void => {
24
+ this.setState({ error: null });
25
+ };
26
+
27
+ render(): ReactNode {
28
+ const { error } = this.state;
29
+ const { fallback, children } = this.props;
30
+ if (error) {
31
+ if (typeof fallback === 'function') {
32
+ return fallback(error, this.reset);
33
+ }
34
+ return fallback ?? null;
35
+ }
36
+ return children;
37
+ }
38
+ }
@@ -0,0 +1,36 @@
1
+ import { captureError } from '../capture';
2
+
3
+ type ErrorUtilsHandler = (error: Error, isFatal?: boolean) => void;
4
+
5
+ type ErrorUtilsLike = {
6
+ setGlobalHandler: (handler: ErrorUtilsHandler) => void;
7
+ getGlobalHandler: () => ErrorUtilsHandler;
8
+ };
9
+
10
+ let _previous: ErrorUtilsHandler | undefined;
11
+ let _installed = false;
12
+
13
+ export const installGlobalHandler = (): void => {
14
+ if (_installed) return;
15
+
16
+ const utils = (globalThis as { ErrorUtils?: ErrorUtilsLike }).ErrorUtils;
17
+ if (!utils || typeof utils.setGlobalHandler !== 'function') return;
18
+
19
+ _installed = true;
20
+ _previous = utils.getGlobalHandler();
21
+
22
+ utils.setGlobalHandler((error, isFatal) => {
23
+ try {
24
+ captureError(error);
25
+ } catch {
26
+ // never throw from the global handler
27
+ }
28
+ if (_previous) {
29
+ try {
30
+ _previous(error, isFatal);
31
+ } catch {
32
+ // ignore previous handler error
33
+ }
34
+ }
35
+ });
36
+ };
@@ -0,0 +1,70 @@
1
+ import { addBreadcrumb } from '../breadcrumbs';
2
+
3
+ let _installed = false;
4
+
5
+ const AUTH_PARAMS = ['token', 'key', 'password', 'secret', 'access_token'];
6
+
7
+ export const installNetworkHandler = (): void => {
8
+ if (_installed) return;
9
+ if (typeof globalThis.fetch !== 'function') return;
10
+ _installed = true;
11
+
12
+ const original = globalThis.fetch;
13
+
14
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
15
+ const start = Date.now();
16
+ const url = extractUrl(input);
17
+ const method = (init?.method ??
18
+ (typeof input !== 'string' && 'method' in (input as Request)
19
+ ? (input as Request).method
20
+ : 'GET')) as string;
21
+
22
+ try {
23
+ const resp = await original(input, init);
24
+ addBreadcrumb({
25
+ type: 'net',
26
+ data: {
27
+ method,
28
+ url: scrubUrl(url),
29
+ status: resp.status,
30
+ durationMs: Date.now() - start,
31
+ },
32
+ });
33
+ return resp;
34
+ } catch (e) {
35
+ addBreadcrumb({
36
+ type: 'net',
37
+ data: {
38
+ method,
39
+ url: scrubUrl(url),
40
+ status: 0,
41
+ durationMs: Date.now() - start,
42
+ error: String(e),
43
+ },
44
+ });
45
+ throw e;
46
+ }
47
+ }) as typeof fetch;
48
+ };
49
+
50
+ const extractUrl = (input: RequestInfo | URL): string => {
51
+ if (typeof input === 'string') return input;
52
+ if (input instanceof URL) return input.href;
53
+ return (input as Request).url;
54
+ };
55
+
56
+ const scrubUrl = (url: string): string => {
57
+ try {
58
+ const u = new URL(url);
59
+ let modified = false;
60
+ for (const p of AUTH_PARAMS) {
61
+ if (u.searchParams.has(p)) {
62
+ u.searchParams.set(p, '[redacted]');
63
+ modified = true;
64
+ }
65
+ }
66
+ return modified ? u.toString() : url;
67
+ } catch {
68
+ return url;
69
+ }
70
+ };
@@ -0,0 +1,38 @@
1
+ import { captureError } from '../capture';
2
+
3
+ type RejectionTracker = (opts: {
4
+ allRejections: boolean;
5
+ onUnhandled: (id: number, rejection: unknown) => void;
6
+ }) => void;
7
+
8
+ type HermesInternalLike = {
9
+ enablePromiseRejectionTracker?: RejectionTracker;
10
+ };
11
+
12
+ let _installed = false;
13
+
14
+ export const installPromiseHandler = (): void => {
15
+ if (_installed) return;
16
+
17
+ const hermes = (globalThis as { HermesInternal?: HermesInternalLike })
18
+ .HermesInternal;
19
+ if (hermes?.enablePromiseRejectionTracker) {
20
+ _installed = true;
21
+ hermes.enablePromiseRejectionTracker({
22
+ allRejections: true,
23
+ onUnhandled: (_id, rejection) => {
24
+ try {
25
+ const err =
26
+ rejection instanceof Error ? rejection : new Error(String(rejection));
27
+ captureError(err);
28
+ } catch {
29
+ // never throw
30
+ }
31
+ },
32
+ });
33
+ return;
34
+ }
35
+
36
+ // No-op fallback: on JSC or older Hermes the SDK can't track rejections
37
+ // without a polyfill. Users targeting these can call `captureError(err)` manually.
38
+ };
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { init } from './init';
2
+ import { addBreadcrumb } from './breadcrumbs';
3
+ import { setUser, getUser, captureError, captureException } from './capture';
4
+ import { ErrorBoundary } from './error-boundary';
5
+
6
+ export const sentori = {
7
+ init,
8
+ addBreadcrumb,
9
+ setUser,
10
+ getUser,
11
+ captureError,
12
+ captureException,
13
+ ErrorBoundary,
14
+ };
15
+
16
+ export default sentori;
17
+
18
+ export { init } from './init';
19
+ export { addBreadcrumb } from './breadcrumbs';
20
+ export { setUser, getUser, captureError, captureException } from './capture';
21
+ export { ErrorBoundary } from './error-boundary';
22
+ export { triggerNativeCrash } from './native';
23
+
24
+ export type {
25
+ Event,
26
+ SentoriError,
27
+ Frame,
28
+ Breadcrumb,
29
+ BreadcrumbType,
30
+ Device,
31
+ DeviceOS,
32
+ App,
33
+ User,
34
+ Tags,
35
+ EventKind,
36
+ Platform,
37
+ } from './types';
package/src/init.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { setConfig } from './config';
2
+ import { installGlobalHandler } from './handlers/global';
3
+ import { installPromiseHandler } from './handlers/promise';
4
+ import { installNetworkHandler } from './handlers/network';
5
+ import { drainNativePending, setNativeConfig } from './native';
6
+ import { drainOfflineQueue, enqueue, startTransport } from './transport';
7
+ import type { Event } from './types';
8
+
9
+ declare const __DEV__: boolean | undefined;
10
+
11
+ export type InitOptions = {
12
+ /** Project token starting with `st_pk_`. Required. */
13
+ token: string;
14
+ /** Release identifier, e.g. `myapp@1.2.3+456`. Required. */
15
+ release: string;
16
+ /** Environment label. Defaults to `dev` if `__DEV__`, else `prod`. */
17
+ environment?: string;
18
+ /** Override ingestion URL (self-hosted). Default: https://ingest.sentori.golia.jp */
19
+ ingestUrl?: string;
20
+ /** Toggle individual capture sources. All enabled by default. */
21
+ capture?: {
22
+ globalErrors?: boolean;
23
+ promiseRejections?: boolean;
24
+ network?: boolean;
25
+ };
26
+ };
27
+
28
+ const DEFAULT_INGEST_URL = 'https://ingest.sentori.golia.jp';
29
+
30
+ export const init = (options: InitOptions): void => {
31
+ if (!options.token || !options.token.startsWith('st_pk_')) {
32
+ throw new Error("Sentori: token is required and must start with 'st_pk_'");
33
+ }
34
+ if (!options.release) {
35
+ throw new Error('Sentori: release is required');
36
+ }
37
+
38
+ const env =
39
+ options.environment ??
40
+ (typeof __DEV__ !== 'undefined' && __DEV__ ? 'dev' : 'prod');
41
+
42
+ setConfig({
43
+ token: options.token,
44
+ release: options.release,
45
+ environment: env,
46
+ ingestUrl: options.ingestUrl ?? DEFAULT_INGEST_URL,
47
+ enabled: true,
48
+ });
49
+
50
+ // Tell the native crash handler about the config so the JSON it writes
51
+ // on the next NSException / Java uncaught carries release + env.
52
+ setNativeConfig({
53
+ token: options.token,
54
+ release: options.release,
55
+ environment: env,
56
+ });
57
+
58
+ startTransport();
59
+
60
+ const capture = options.capture ?? {};
61
+ if (capture.globalErrors !== false) installGlobalHandler();
62
+ if (capture.promiseRejections !== false) installPromiseHandler();
63
+ if (capture.network !== false) installNetworkHandler();
64
+
65
+ // Drain events persisted from previous session (best-effort):
66
+ // - native crashes from <Documents>/sentori/pending/*.json
67
+ // - JS transport offline queue from AsyncStorage
68
+ drainNativePending()
69
+ .then((items) => {
70
+ for (const json of items) {
71
+ try {
72
+ enqueue(JSON.parse(json) as Event);
73
+ } catch {
74
+ // skip malformed
75
+ }
76
+ }
77
+ })
78
+ .catch(() => {});
79
+ drainOfflineQueue().catch(() => {});
80
+ };
package/src/native.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Bridge to the native (iOS / Android) Sentori module.
3
+ * No-op when not running in an Expo runtime that has the module installed —
4
+ * this keeps the SDK usable in pure-JS environments (jest, bun test, web).
5
+ */
6
+
7
+ type SentoriNativeModule = {
8
+ drainPending: () => Promise<string[]>
9
+ setConfig: (config: {
10
+ environment: string
11
+ release: string
12
+ token: string
13
+ }) => void
14
+ /** Dev-only — example app uses this to verify the crash flow. */
15
+ triggerTestNativeCrash?: () => void
16
+ }
17
+
18
+ let _native: SentoriNativeModule | null | undefined
19
+
20
+ function native(): SentoriNativeModule | null {
21
+ if (_native !== undefined) return _native
22
+ try {
23
+ const core = require('expo-modules-core') as {
24
+ requireNativeModule: <T>(name: string) => T
25
+ }
26
+ _native = core.requireNativeModule<SentoriNativeModule>('Sentori')
27
+ } catch {
28
+ _native = null
29
+ }
30
+ return _native
31
+ }
32
+
33
+ export function setNativeConfig(config: {
34
+ environment: string
35
+ release: string
36
+ token: string
37
+ }): void {
38
+ try {
39
+ native()?.setConfig(config)
40
+ } catch {
41
+ // never throw on init
42
+ }
43
+ }
44
+
45
+ export async function drainNativePending(): Promise<string[]> {
46
+ const n = native()
47
+ if (!n) return []
48
+ try {
49
+ return await n.drainPending()
50
+ } catch {
51
+ return []
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Dev-only helper. Triggers a real NSException (iOS) or RuntimeException
57
+ * (Android) after a short delay so the host app crashes for real and the
58
+ * native crash handler exercises the full write-to-disk path.
59
+ *
60
+ * Usage: tap a button in the example app, watch the app close, restart it,
61
+ * verify the server received the event.
62
+ *
63
+ * No-op when the native module isn't installed (jest, bun test, web).
64
+ */
65
+ export function triggerNativeCrash(): void {
66
+ try {
67
+ native()?.triggerTestNativeCrash?.()
68
+ } catch {
69
+ // never throw from a debugging helper
70
+ }
71
+ }
package/src/stack.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { Frame } from './types';
2
+
3
+ // V8 / Hermes (RN 0.71+):
4
+ // " at functionName (file:line:col)"
5
+ // " at file:line:col"
6
+ const V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?)(?::(\d+))?(?::(\d+))?\)?\s*$/;
7
+
8
+ // SpiderMonkey / older Hermes:
9
+ // "functionName@file:line:col"
10
+ const AT_FRAME = /^(.+?)@(.+?)(?::(\d+))?(?::(\d+))?$/;
11
+
12
+ export const parseStack = (stack: string | undefined): Frame[] => {
13
+ if (!stack || typeof stack !== 'string') return [];
14
+ const lines = stack.split('\n');
15
+ const frames: Frame[] = [];
16
+
17
+ for (const raw of lines) {
18
+ const line = raw.trim();
19
+ if (!line) continue;
20
+ // Skip the "ErrorType: message" header line.
21
+ if (!line.startsWith('at ') && !line.includes('@')) continue;
22
+
23
+ const frame = parseV8(line) ?? parseAt(line);
24
+ if (frame) frames.push(frame);
25
+ }
26
+
27
+ return frames;
28
+ };
29
+
30
+ const parseV8 = (line: string): Frame | null => {
31
+ if (!line.startsWith('at ')) return null;
32
+ const m = V8_FRAME.exec(line);
33
+ if (!m) return null;
34
+
35
+ const fn = m[1] ? m[1].trim() : undefined;
36
+ const file = m[2] ? m[2].trim() : '<anonymous>';
37
+ const lineNo = m[3] ? parseInt(m[3], 10) : 0;
38
+ const col = m[4] ? parseInt(m[4], 10) : undefined;
39
+
40
+ return {
41
+ function: fn,
42
+ file,
43
+ line: lineNo,
44
+ column: col,
45
+ inApp: isInApp(file),
46
+ };
47
+ };
48
+
49
+ const parseAt = (line: string): Frame | null => {
50
+ const m = AT_FRAME.exec(line);
51
+ if (!m) return null;
52
+
53
+ const fn = m[1] ? m[1].trim() : undefined;
54
+ const file = m[2] ? m[2].trim() : '<anonymous>';
55
+ const lineNo = m[3] ? parseInt(m[3], 10) : 0;
56
+ const col = m[4] ? parseInt(m[4], 10) : undefined;
57
+
58
+ return {
59
+ function: fn,
60
+ file,
61
+ line: lineNo,
62
+ column: col,
63
+ inApp: isInApp(file),
64
+ };
65
+ };
66
+
67
+ const isInApp = (file: string): boolean => {
68
+ if (!file || file === '<anonymous>') return false;
69
+ if (file.includes('node_modules/')) return false;
70
+ if (/^https?:\/\//.test(file)) return false;
71
+ return true;
72
+ };