rn-backstage 1.4.3 → 1.4.4

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/README.md CHANGED
@@ -104,7 +104,6 @@ Pass a `storageAdapter` to render a **Storage** tab that lets you browse, edit,
104
104
 
105
105
  ```tsx
106
106
  import AsyncStorage from '@react-native-async-storage/async-storage'
107
-
108
107
  ;<Backstage
109
108
  storageAdapter={{
110
109
  getAllKeys: () => AsyncStorage.getAllKeys(),
@@ -119,7 +118,6 @@ MMKV example:
119
118
 
120
119
  ```tsx
121
120
  import { storage } from './mmkv'
122
-
123
121
  ;<Backstage
124
122
  storageAdapter={{
125
123
  getAllKeys: () => Promise.resolve(storage.getAllKeys()),
@@ -181,7 +179,7 @@ Add a `bugReport` config to enable one-tap bug reporting. Tapping the 🐛 butto
181
179
  | `pillWidth` | `number` | `60` | Width of the floating pill |
182
180
  | `pillHeight` | `number` | `32` | Height of the floating pill |
183
181
  | `extraTabs` | `BackstageTab[]` | `[]` | Additional custom tabs |
184
- | `bugReport` | `BugReportConfig` | `undefined` | Bug report config (shows 🐛 button in header) |
182
+ | `bugReport` | `BugReportConfig` | `undefined` | Bug report config (shows 🐛 button in header) |
185
183
  | `styles` | `BackstageStyleOverrides` | `undefined` | Custom style overrides |
186
184
  | `children` | `ReactNode` | `undefined` | Extra content in InfoTab |
187
185
 
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "rn-backstage",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "A zero-dependency developer/QA debug panel for React Native apps",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
7
7
  "types": "lib/typescript/index.d.ts",
8
- "react-native": "lib/module/index",
8
+ "react-native": "src/index",
9
9
  "source": "src/index",
10
10
  "files": [
11
+ "src",
11
12
  "lib",
12
- "README.md",
13
13
  "!**/__tests__",
14
14
  "!**/__fixtures__",
15
15
  "!**/__mocks__",
@@ -0,0 +1,227 @@
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useRef,
7
+ useState,
8
+ } from 'react'
9
+ import { StyleSheet, View } from 'react-native'
10
+ import { DEFAULT_MAX_LOGS, DEFAULT_MAX_NETWORK_ENTRIES } from './constants'
11
+ import { installInterceptor, LogBuffer, uninstallInterceptor } from './log-interceptor'
12
+ import {
13
+ installNetworkInterceptor,
14
+ NetworkBuffer,
15
+ uninstallNetworkInterceptor,
16
+ } from './network-interceptor'
17
+ import { FloatingPill } from './components/FloatingPill'
18
+ import { BackstagePanel } from './components/BackstagePanel'
19
+ import { BackstageThemeProvider } from './ThemeContext'
20
+ import type { BackstageProps, BackstageRef, LogEntry, NetworkEntry } from './types'
21
+
22
+ // ─── Component ───────────────────────────────────────────────────────────────
23
+
24
+ export const Backstage = forwardRef<BackstageRef, BackstageProps>(
25
+ (
26
+ {
27
+ visible = true,
28
+ theme: themePreference = 'auto',
29
+ appVersion,
30
+ buildNumber,
31
+ bundleId,
32
+ deviceInfo,
33
+ state,
34
+ quickActions,
35
+ featureFlags,
36
+ onToggleFeatureFlag,
37
+ storageAdapter,
38
+ maxLogs = DEFAULT_MAX_LOGS,
39
+ logFilters,
40
+ onCopyLogs,
41
+ extraTabs,
42
+ children,
43
+ styles: propStyles,
44
+ initialX,
45
+ initialY,
46
+ pillText,
47
+ pillWidth,
48
+ pillHeight,
49
+ enableNetworkInspector = true,
50
+ maxNetworkEntries = DEFAULT_MAX_NETWORK_ENTRIES,
51
+ maxNetworkBodySize,
52
+ networkFilters,
53
+ autoFilterNetworkLogs = true,
54
+ jsonMaxDepth,
55
+ bugReport: bugReportConfig,
56
+ },
57
+ ref,
58
+ ) => {
59
+ const [panelVisible, setPanelVisible] = useState(false)
60
+ const [hasError, setHasError] = useState(false)
61
+ const [logs, setLogs] = useState<LogEntry[]>([])
62
+ const [networkEntries, setNetworkEntries] = useState<NetworkEntry[]>([])
63
+
64
+ const logBuffer = useRef(new LogBuffer(maxLogs))
65
+ const networkBuffer = useRef(new NetworkBuffer(maxNetworkEntries))
66
+
67
+ // ── Panel controls ────────────────────────────────────────
68
+
69
+ const openPanel = useCallback(() => {
70
+ setPanelVisible(true)
71
+ // Sync logs and network entries when opening panel
72
+ setLogs(logBuffer.current.getAll())
73
+ setNetworkEntries(networkBuffer.current.getAll())
74
+ }, [])
75
+
76
+ const closePanel = useCallback(() => {
77
+ setPanelVisible(false)
78
+ }, [])
79
+
80
+ const clearLogs = useCallback(() => {
81
+ logBuffer.current.clear()
82
+ setLogs([])
83
+ setHasError(false)
84
+ }, [])
85
+
86
+ // ── Refresh logs ──────────────────────────────────────────────────
87
+
88
+ const refreshLogs = useCallback(() => {
89
+ setLogs(logBuffer.current.getAll())
90
+ }, [])
91
+
92
+ // ── Network controls ──────────────────────────────────────────────
93
+
94
+ const refreshNetwork = useCallback(() => {
95
+ setNetworkEntries(networkBuffer.current.getAll())
96
+ }, [])
97
+
98
+ const clearNetwork = useCallback(() => {
99
+ networkBuffer.current.clear()
100
+ setNetworkEntries([])
101
+ }, [])
102
+
103
+ // ── Install console interceptor ───────────────────────────
104
+
105
+ useEffect(() => {
106
+ const handleLogEntry = (entry: LogEntry) => {
107
+ logBuffer.current.push(entry)
108
+
109
+ // Track error state for the floating pill
110
+ if (entry.level === 'error') {
111
+ setHasError(true)
112
+ }
113
+ }
114
+
115
+ installInterceptor(handleLogEntry, logFilters, {
116
+ autoFilterNetworkLogs: enableNetworkInspector && autoFilterNetworkLogs,
117
+ })
118
+
119
+ return () => {
120
+ uninstallInterceptor()
121
+ }
122
+ }, [logFilters, enableNetworkInspector, autoFilterNetworkLogs])
123
+
124
+ // ── Install network interceptor ───────────────────────────────
125
+
126
+ useEffect(() => {
127
+ if (!enableNetworkInspector) return
128
+
129
+ const handleNetworkEntry = (entry: NetworkEntry) => {
130
+ networkBuffer.current.upsert(entry)
131
+
132
+ // Auto-refresh if panel is open
133
+ if (panelVisible) {
134
+ setNetworkEntries(networkBuffer.current.getAll())
135
+ }
136
+ }
137
+
138
+ installNetworkInterceptor(handleNetworkEntry, {
139
+ filters: networkFilters,
140
+ maxBodySize: maxNetworkBodySize,
141
+ })
142
+
143
+ return () => {
144
+ uninstallNetworkInterceptor()
145
+ }
146
+ }, [enableNetworkInspector, networkFilters, maxNetworkBodySize, panelVisible])
147
+
148
+ // ── Expose ref methods ────────────────────────────────────
149
+
150
+ const openBugReportRef = useRef<(() => void) | null>(null)
151
+
152
+ useImperativeHandle(ref, () => ({
153
+ open: openPanel,
154
+ close: closePanel,
155
+ clearLogs,
156
+ submitBugReport: () => {
157
+ if (bugReportConfig) {
158
+ openPanel()
159
+ // Small delay to let panel mount first
160
+ setTimeout(() => openBugReportRef.current?.(), 100)
161
+ }
162
+ },
163
+ }))
164
+
165
+ // ── Render ────────────────────────────────────────────────
166
+
167
+ if (!visible) return null
168
+
169
+ const displayText = pillText || (appVersion ? `v${appVersion}` : 'DEV')
170
+
171
+ return (
172
+ <BackstageThemeProvider preference={themePreference}>
173
+ <View style={styles.container} pointerEvents="box-none">
174
+ <FloatingPill
175
+ text={displayText}
176
+ hasError={hasError}
177
+ onPress={openPanel}
178
+ initialX={initialX}
179
+ initialY={initialY}
180
+ pillWidth={pillWidth}
181
+ pillHeight={pillHeight}
182
+ styles={propStyles}
183
+ />
184
+ <BackstagePanel
185
+ visible={panelVisible}
186
+ onClose={closePanel}
187
+ appVersion={appVersion}
188
+ buildNumber={buildNumber}
189
+ bundleId={bundleId}
190
+ deviceInfo={deviceInfo}
191
+ state={state}
192
+ quickActions={quickActions}
193
+ featureFlags={featureFlags}
194
+ onToggleFeatureFlag={onToggleFeatureFlag}
195
+ logs={logs}
196
+ onRefreshLogs={refreshLogs}
197
+ onCopyLogs={onCopyLogs}
198
+ networkEntries={networkEntries}
199
+ onRefreshNetwork={refreshNetwork}
200
+ onClearNetwork={clearNetwork}
201
+ onCopyNetwork={onCopyLogs}
202
+ extraTabs={extraTabs}
203
+ jsonMaxDepth={jsonMaxDepth}
204
+ storageAdapter={storageAdapter}
205
+ bugReportConfig={bugReportConfig}
206
+ bugReportOpenerRef={openBugReportRef}
207
+ styles={propStyles}
208
+ >
209
+ {children}
210
+ </BackstagePanel>
211
+ </View>
212
+ </BackstageThemeProvider>
213
+ )
214
+ },
215
+ )
216
+
217
+ Backstage.displayName = 'Backstage'
218
+
219
+ // ─── Styles ──────────────────────────────────────────────────────────────────
220
+
221
+ const styles = StyleSheet.create({
222
+ container: {
223
+ ...StyleSheet.absoluteFillObject,
224
+ zIndex: 99999,
225
+ elevation: 99999,
226
+ },
227
+ })
@@ -0,0 +1,38 @@
1
+ import React, { createContext, useContext, useMemo } from 'react'
2
+ import { useColorScheme } from 'react-native'
3
+ import { DarkTheme, LightTheme } from './constants'
4
+ import type { BackstageTheme } from './types'
5
+
6
+ // ─── Theme Preference ────────────────────────────────────────────────────────
7
+
8
+ export type ThemePreference = 'light' | 'dark' | 'auto'
9
+
10
+ // ─── Context ─────────────────────────────────────────────────────────────────
11
+
12
+ const ThemeContext = createContext<BackstageTheme>(DarkTheme)
13
+
14
+ // ─── Provider ────────────────────────────────────────────────────────────────
15
+
16
+ interface ThemeProviderProps {
17
+ preference: ThemePreference
18
+ children: React.ReactNode
19
+ }
20
+
21
+ export const BackstageThemeProvider: React.FC<ThemeProviderProps> = ({ preference, children }) => {
22
+ const systemScheme = useColorScheme()
23
+
24
+ const theme = useMemo(() => {
25
+ if (preference === 'light') return LightTheme
26
+ if (preference === 'dark') return DarkTheme
27
+ // 'auto' — follow system, fallback to dark
28
+ return systemScheme === 'light' ? LightTheme : DarkTheme
29
+ }, [preference, systemScheme])
30
+
31
+ return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
32
+ }
33
+
34
+ // ─── Hook ────────────────────────────────────────────────────────────────────
35
+
36
+ export function useBackstageTheme(): BackstageTheme {
37
+ return useContext(ThemeContext)
38
+ }
@@ -0,0 +1,184 @@
1
+ import { Platform } from 'react-native'
2
+ import type {
3
+ AppInfoItem,
4
+ BugReport,
5
+ BugReportSeverity,
6
+ LogEntry,
7
+ NetworkEntry,
8
+ } from './types'
9
+ import { LogLevel } from './types'
10
+
11
+ // ─── Report Composition ──────────────────────────────────────────────────────
12
+
13
+ const SEVERITY_LABELS: Record<BugReportSeverity, string> = {
14
+ low: 'đŸŸĸ Low',
15
+ medium: '🟡 Medium',
16
+ high: '🟠 High',
17
+ critical: '🔴 Critical',
18
+ }
19
+
20
+ function formatTimestamp(ts: number): string {
21
+ const d = new Date(ts)
22
+ return d.toISOString().replace('T', ' ').substring(0, 19)
23
+ }
24
+
25
+ function formatLogLevel(level: LogLevel): string {
26
+ switch (level) {
27
+ case LogLevel.error:
28
+ return '❌ ERROR'
29
+ case LogLevel.warn:
30
+ return 'âš ī¸ WARN'
31
+ case LogLevel.info:
32
+ return 'â„šī¸ INFO'
33
+ case LogLevel.debug:
34
+ return '🔍 DEBUG'
35
+ default:
36
+ return '📝 LOG'
37
+ }
38
+ }
39
+
40
+ export function formatDeviceInfoForReport(items: AppInfoItem[]): string {
41
+ if (items.length === 0) return ''
42
+ const lines = items.map(i => `- **${i.label}**: ${i.value}`)
43
+ return `## 📱 Device Info\n\n${lines.join('\n')}\n`
44
+ }
45
+
46
+ export function formatLogsForReport(logs: LogEntry[]): string {
47
+ if (logs.length === 0) return ''
48
+ const lines = logs.map(
49
+ l => `\`${formatTimestamp(l.timestamp)}\` ${formatLogLevel(l.level)} ${l.message}`,
50
+ )
51
+ return `## 📋 Console Logs (${logs.length})\n\n\`\`\`\n${lines.join('\n')}\n\`\`\`\n`
52
+ }
53
+
54
+ export function formatNetworkForReport(entries: NetworkEntry[]): string {
55
+ if (entries.length === 0) return ''
56
+ const lines = entries.map(e => {
57
+ const status = e.status ?? 'âŗ'
58
+ const duration = e.duration ? `${e.duration}ms` : '...'
59
+ return `- \`${e.method}\` ${e.url} → ${status} (${duration})`
60
+ })
61
+ return `## 🌐 Network Activity (${entries.length})\n\n${lines.join('\n')}\n`
62
+ }
63
+
64
+ export function formatStateForReport(state: Record<string, unknown>): string {
65
+ try {
66
+ const json = JSON.stringify(state, null, 2)
67
+ // Truncate if too large
68
+ const truncated = json.length > 5000 ? json.substring(0, 5000) + '\n... (truncated)' : json
69
+ return `## đŸŒŗ State Snapshot\n\n\`\`\`json\n${truncated}\n\`\`\`\n`
70
+ } catch {
71
+ return `## đŸŒŗ State Snapshot\n\n\`[Could not serialize state]\`\n`
72
+ }
73
+ }
74
+
75
+ export function composeBugReport(report: BugReport): string {
76
+ const sections: string[] = []
77
+
78
+ sections.push(`# 🐛 Bug Report: ${report.title}\n`)
79
+ sections.push(`**Severity**: ${SEVERITY_LABELS[report.severity]}`)
80
+ sections.push(`**Reported**: ${formatTimestamp(report.timestamp)}`)
81
+ sections.push(`**Platform**: ${Platform.OS} ${Platform.Version}\n`)
82
+
83
+ if (report.description) {
84
+ sections.push(`## Description\n\n${report.description}\n`)
85
+ }
86
+
87
+ if (report.deviceInfo.length > 0) {
88
+ sections.push(formatDeviceInfoForReport(report.deviceInfo))
89
+ }
90
+
91
+ if (report.logs.length > 0) {
92
+ sections.push(formatLogsForReport(report.logs))
93
+ }
94
+
95
+ if (report.networkEntries.length > 0) {
96
+ sections.push(formatNetworkForReport(report.networkEntries))
97
+ }
98
+
99
+ if (report.state && Object.keys(report.state).length > 0) {
100
+ sections.push(formatStateForReport(report.state))
101
+ }
102
+
103
+ if (report.screenshotUri) {
104
+ sections.push(`## 📸 Screenshot\n\nAttached: ${report.screenshotUri}\n`)
105
+ }
106
+
107
+ sections.push('---\n*Generated by rn-backstage*')
108
+
109
+ return sections.join('\n')
110
+ }
111
+
112
+ // ─── Webhook Submission ──────────────────────────────────────────────────────
113
+
114
+ export async function submitToWebhook(
115
+ webhookUrl: string,
116
+ report: BugReport,
117
+ ): Promise<{ success: boolean; error?: string }> {
118
+ try {
119
+ const body: Record<string, unknown> = {
120
+ title: report.title,
121
+ description: report.description,
122
+ severity: report.severity,
123
+ platform: Platform.OS,
124
+ platformVersion: String(Platform.Version),
125
+ timestamp: report.timestamp,
126
+ deviceInfo: report.deviceInfo,
127
+ logs: report.logs.map(l => ({
128
+ level: l.level,
129
+ message: l.message,
130
+ timestamp: l.timestamp,
131
+ })),
132
+ networkEntries: report.networkEntries.map(e => ({
133
+ method: e.method,
134
+ url: e.url,
135
+ status: e.status,
136
+ duration: e.duration,
137
+ })),
138
+ }
139
+
140
+ if (report.state) {
141
+ body.state = report.state
142
+ }
143
+
144
+ if (report.screenshotUri) {
145
+ body.screenshotUri = report.screenshotUri
146
+ }
147
+
148
+ const response = await fetch(webhookUrl, {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify(body),
152
+ })
153
+
154
+ if (!response.ok) {
155
+ return { success: false, error: `HTTP ${response.status}` }
156
+ }
157
+
158
+ return { success: true }
159
+ } catch (err) {
160
+ return {
161
+ success: false,
162
+ error: err instanceof Error ? err.message : 'Unknown error',
163
+ }
164
+ }
165
+ }
166
+
167
+ // ─── Device Info Builder ─────────────────────────────────────────────────────
168
+
169
+ export function buildDeviceInfo(
170
+ appVersion?: string,
171
+ buildNumber?: string,
172
+ bundleId?: string,
173
+ extraInfo: AppInfoItem[] = [],
174
+ ): AppInfoItem[] {
175
+ const info: AppInfoItem[] = [
176
+ { label: 'Platform', value: `${Platform.OS} ${Platform.Version}` },
177
+ ]
178
+
179
+ if (appVersion) info.push({ label: 'App Version', value: appVersion })
180
+ if (buildNumber) info.push({ label: 'Build Number', value: buildNumber })
181
+ if (bundleId) info.push({ label: 'Bundle ID', value: bundleId })
182
+
183
+ return [...info, ...extraInfo]
184
+ }