rn-backstage 1.4.2 → 1.4.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.
@@ -1,468 +0,0 @@
1
- import { NetworkState } from './types'
2
- import type { NetworkEntry } from './types'
3
- import { DEFAULT_MAX_NETWORK_ENTRIES, DEFAULT_MAX_NETWORK_BODY_SIZE } from './constants'
4
-
5
- // ─── Types ───────────────────────────────────────────────────────────────────
6
-
7
- type NetworkCallback = (entry: NetworkEntry) => void
8
-
9
- interface NetworkInterceptorOptions {
10
- filters?: string[]
11
- maxBodySize?: number
12
- }
13
-
14
- // ─── State ───────────────────────────────────────────────────────────────────
15
-
16
- let activeCallback: NetworkCallback | null = null
17
- let isInstalled = false
18
- let interceptorOptions: NetworkInterceptorOptions = {}
19
-
20
- // Track if we're inside a network callback context.
21
- // Used by the log interceptor to auto-filter network-related console.logs.
22
- let _activeNetworkCallbacks = 0
23
-
24
- /**
25
- * Returns true if the current call stack originated from within a network
26
- * response handler (XHR load/error, fetch .then()). Used by the log
27
- * interceptor to suppress network-related console.logs from the Logs tab.
28
- */
29
- export function isInsideNetworkCallback(): boolean {
30
- return _activeNetworkCallbacks > 0
31
- }
32
-
33
- function enterNetworkContext(): void {
34
- _activeNetworkCallbacks++
35
- }
36
-
37
- function exitNetworkContext(): void {
38
- // Defer decrement through microtask queue so developer's .then() chains
39
- // that fire after our return are also covered.
40
- queueMicrotask(() => {
41
- _activeNetworkCallbacks--
42
- })
43
- }
44
-
45
- // Store originals before patching
46
- const originals: {
47
- fetch: typeof global.fetch | null
48
- XMLHttpRequest: typeof global.XMLHttpRequest | null
49
- } = {
50
- fetch: null,
51
- XMLHttpRequest: null,
52
- }
53
-
54
- // ─── Helpers ─────────────────────────────────────────────────────────────────
55
-
56
- function generateId(): string {
57
- return `net_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
58
- }
59
-
60
- function shouldCapture(url: string, filters: string[]): boolean {
61
- if (filters.length === 0) return true
62
- const lower = url.toLowerCase()
63
- return !filters.some(f => lower.includes(f.toLowerCase()))
64
- }
65
-
66
- function truncateBody(body?: string, maxSize?: number): string | undefined {
67
- if (!body) return undefined
68
- const limit = maxSize ?? DEFAULT_MAX_NETWORK_BODY_SIZE
69
- if (body.length <= limit) return body
70
- return body.substring(0, limit) + '\n... [truncated]'
71
- }
72
-
73
- function parseHeaders(headers: Headers | undefined): Record<string, string> | undefined {
74
- if (!headers) return undefined
75
- const result: Record<string, string> = {}
76
- headers.forEach((value: string, key: string) => {
77
- result[key] = value
78
- })
79
- return Object.keys(result).length > 0 ? result : undefined
80
- }
81
-
82
- /**
83
- * Generates a cURL command string from a NetworkEntry.
84
- */
85
- export function toCurl(entry: NetworkEntry): string {
86
- const parts: string[] = ['curl']
87
-
88
- // Method
89
- if (entry.method && entry.method !== 'GET') {
90
- parts.push(`-X ${entry.method}`)
91
- }
92
-
93
- // Request headers
94
- if (entry.requestHeaders) {
95
- for (const [key, value] of Object.entries(entry.requestHeaders)) {
96
- parts.push(`-H '${key}: ${value}'`)
97
- }
98
- }
99
-
100
- // Request body
101
- if (entry.requestBody) {
102
- // Escape single quotes in body
103
- const escaped = entry.requestBody.replace(/'/g, "'\\''")
104
- parts.push(`-d '${escaped}'`)
105
- }
106
-
107
- // URL (must be last)
108
- parts.push(`'${entry.url}'`)
109
-
110
- return parts.join(' \\\n ')
111
- }
112
-
113
- // ─── Fetch Interceptor ──────────────────────────────────────────────────────
114
-
115
- function installFetchInterceptor(): void {
116
- const originalFetch = global.fetch
117
- if (!originalFetch) return
118
- originals.fetch = originalFetch
119
-
120
- global.fetch = async function interceptedFetch(
121
- input: RequestInfo | URL,
122
- init?: RequestInit,
123
- ): Promise<Response> {
124
- const url =
125
- typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
126
- const method =
127
- init?.method ??
128
- (typeof input !== 'string' && !(input instanceof URL) ? input.method : 'GET') ??
129
- 'GET'
130
-
131
- // Check filters
132
- if (!shouldCapture(url, interceptorOptions.filters ?? [])) {
133
- return originalFetch(input, init)
134
- }
135
-
136
- // Build entry
137
- const entry: NetworkEntry = {
138
- id: generateId(),
139
- method: method.toUpperCase(),
140
- url,
141
- startTime: Date.now(),
142
- state: NetworkState.pending,
143
- }
144
-
145
- // Capture request headers
146
- if (init?.headers) {
147
- try {
148
- const h = new Headers(init.headers)
149
- entry.requestHeaders = parseHeaders(h)
150
- } catch {
151
- // Skip if headers can't be parsed
152
- }
153
- }
154
-
155
- // Capture request body
156
- if (init?.body) {
157
- try {
158
- entry.requestBody = truncateBody(
159
- typeof init.body === 'string' ? init.body : JSON.stringify(init.body),
160
- interceptorOptions.maxBodySize,
161
- )
162
- } catch {
163
- entry.requestBody = '[unable to serialize body]'
164
- }
165
- }
166
-
167
- // Notify: request started
168
- activeCallback?.(entry)
169
-
170
- try {
171
- const response = await originalFetch(input, init)
172
-
173
- entry.endTime = Date.now()
174
- entry.duration = entry.endTime - entry.startTime
175
- entry.status = response.status
176
- entry.statusText = response.statusText
177
- entry.responseHeaders = parseHeaders(response.headers)
178
- entry.state = NetworkState.completed
179
-
180
- // Clone response to read body without consuming it
181
- try {
182
- const clone = response.clone()
183
- const text = await clone.text()
184
- entry.responseSize = text.length
185
- entry.responseBody = truncateBody(text, interceptorOptions.maxBodySize)
186
- } catch {
187
- // Body may not be readable (e.g., stream already consumed)
188
- }
189
-
190
- // Notify: request completed
191
- activeCallback?.(entry)
192
-
193
- // Mark network context so developer's .then() console.logs are filtered
194
- enterNetworkContext()
195
-
196
- // Return the ORIGINAL response untouched
197
- // Developer's .then() runs in the next microtask — exitNetworkContext
198
- // defers via queueMicrotask to cover it.
199
- const result = response
200
- exitNetworkContext()
201
- return result
202
- } catch (err) {
203
- entry.endTime = Date.now()
204
- entry.duration = entry.endTime - entry.startTime
205
- entry.state = NetworkState.error
206
- entry.error = err instanceof Error ? err.message : String(err)
207
-
208
- // Notify: request failed
209
- activeCallback?.(entry)
210
-
211
- // Re-throw the original error
212
- throw err
213
- }
214
- }
215
- }
216
-
217
- // ─── XMLHttpRequest Interceptor ──────────────────────────────────────────────
218
-
219
- function installXHRInterceptor(): void {
220
- const OriginalXHR = global.XMLHttpRequest
221
- if (!OriginalXHR) return
222
- originals.XMLHttpRequest = OriginalXHR
223
-
224
- global.XMLHttpRequest = function InterceptedXHR() {
225
- const xhr = new OriginalXHR()
226
- const entry: NetworkEntry = {
227
- id: generateId(),
228
- method: 'GET',
229
- url: '',
230
- startTime: Date.now(),
231
- state: NetworkState.pending,
232
- requestHeaders: {},
233
- }
234
-
235
- let shouldTrack = true
236
-
237
- // Intercept open()
238
- const originalOpen = xhr.open.bind(xhr)
239
- xhr.open = function (method: string, url: string, ...rest: unknown[]) {
240
- entry.method = method.toUpperCase()
241
- entry.url = url
242
- shouldTrack = shouldCapture(url, interceptorOptions.filters ?? [])
243
- // @ts-expect-error — forwarding args to original
244
- return originalOpen(method, url, ...rest)
245
- }
246
-
247
- // Intercept setRequestHeader()
248
- const originalSetRequestHeader = xhr.setRequestHeader.bind(xhr)
249
- xhr.setRequestHeader = function (name: string, value: string) {
250
- if (shouldTrack && entry.requestHeaders) {
251
- entry.requestHeaders[name] = value
252
- }
253
- return originalSetRequestHeader(name, value)
254
- }
255
-
256
- // Intercept send()
257
- const originalSend = xhr.send.bind(xhr)
258
- xhr.send = function (
259
- body?:
260
- | string
261
- | Document
262
- | Blob
263
- | ArrayBufferView
264
- | ArrayBuffer
265
- | FormData
266
- | URLSearchParams
267
- | null,
268
- ) {
269
- if (!shouldTrack) {
270
- return originalSend(body)
271
- }
272
-
273
- entry.startTime = Date.now()
274
-
275
- // Capture request body
276
- if (body) {
277
- try {
278
- entry.requestBody = truncateBody(
279
- typeof body === 'string' ? body : JSON.stringify(body),
280
- interceptorOptions.maxBodySize,
281
- )
282
- } catch {
283
- entry.requestBody = '[unable to serialize body]'
284
- }
285
- }
286
-
287
- // Clean up empty request headers
288
- if (entry.requestHeaders && Object.keys(entry.requestHeaders).length === 0) {
289
- entry.requestHeaders = undefined
290
- }
291
-
292
- // Notify: request started
293
- activeCallback?.(entry)
294
-
295
- // Listen for completion
296
- xhr.addEventListener('load', () => {
297
- enterNetworkContext()
298
-
299
- entry.endTime = Date.now()
300
- entry.duration = entry.endTime - entry.startTime
301
- entry.status = xhr.status
302
- entry.statusText = xhr.statusText
303
- entry.state = NetworkState.completed
304
-
305
- // Parse response headers
306
- const rawHeaders = xhr.getAllResponseHeaders()
307
- if (rawHeaders) {
308
- const parsed: Record<string, string> = {}
309
- rawHeaders
310
- .trim()
311
- .split(/[\r\n]+/)
312
- .forEach(line => {
313
- const idx = line.indexOf(': ')
314
- if (idx > 0) {
315
- parsed[line.substring(0, idx).toLowerCase()] = line.substring(idx + 2)
316
- }
317
- })
318
- entry.responseHeaders = Object.keys(parsed).length > 0 ? parsed : undefined
319
- }
320
-
321
- // Capture response body
322
- try {
323
- const responseText =
324
- xhr.responseType === '' || xhr.responseType === 'text'
325
- ? xhr.responseText
326
- : JSON.stringify(xhr.response)
327
- entry.responseSize = responseText?.length
328
- entry.responseBody = truncateBody(responseText, interceptorOptions.maxBodySize)
329
- } catch {
330
- // Response may not be text-readable
331
- }
332
-
333
- activeCallback?.(entry)
334
- exitNetworkContext()
335
- })
336
-
337
- xhr.addEventListener('error', () => {
338
- enterNetworkContext()
339
- entry.endTime = Date.now()
340
- entry.duration = entry.endTime - entry.startTime
341
- entry.state = NetworkState.error
342
- entry.error = 'Network request failed'
343
- activeCallback?.(entry)
344
- exitNetworkContext()
345
- })
346
-
347
- xhr.addEventListener('timeout', () => {
348
- enterNetworkContext()
349
- entry.endTime = Date.now()
350
- entry.duration = entry.endTime - entry.startTime
351
- entry.state = NetworkState.error
352
- entry.error = 'Request timed out'
353
- activeCallback?.(entry)
354
- exitNetworkContext()
355
- })
356
-
357
- xhr.addEventListener('abort', () => {
358
- enterNetworkContext()
359
- entry.endTime = Date.now()
360
- entry.duration = entry.endTime - entry.startTime
361
- entry.state = NetworkState.error
362
- entry.error = 'Request aborted'
363
- activeCallback?.(entry)
364
- exitNetworkContext()
365
- })
366
-
367
- return originalSend(body)
368
- }
369
-
370
- return xhr
371
- } as unknown as typeof XMLHttpRequest
372
-
373
- // Copy static properties and prototype
374
- Object.defineProperty(global.XMLHttpRequest, 'UNSENT', { value: 0 })
375
- Object.defineProperty(global.XMLHttpRequest, 'OPENED', { value: 1 })
376
- Object.defineProperty(global.XMLHttpRequest, 'HEADERS_RECEIVED', { value: 2 })
377
- Object.defineProperty(global.XMLHttpRequest, 'LOADING', { value: 3 })
378
- Object.defineProperty(global.XMLHttpRequest, 'DONE', { value: 4 })
379
- global.XMLHttpRequest.prototype = OriginalXHR.prototype
380
- }
381
-
382
- // ─── Install / Uninstall ─────────────────────────────────────────────────────
383
-
384
- export function installNetworkInterceptor(
385
- callback: NetworkCallback,
386
- options: NetworkInterceptorOptions = {},
387
- ): void {
388
- if (isInstalled) {
389
- // Update callback and options if already installed
390
- activeCallback = callback
391
- interceptorOptions = options
392
- return
393
- }
394
-
395
- activeCallback = callback
396
- interceptorOptions = options
397
- isInstalled = true
398
-
399
- installFetchInterceptor()
400
- installXHRInterceptor()
401
- }
402
-
403
- export function uninstallNetworkInterceptor(): void {
404
- if (!isInstalled) return
405
-
406
- // Restore originals
407
- if (originals.fetch) {
408
- global.fetch = originals.fetch
409
- originals.fetch = null
410
- }
411
- if (originals.XMLHttpRequest) {
412
- global.XMLHttpRequest = originals.XMLHttpRequest
413
- originals.XMLHttpRequest = null
414
- }
415
-
416
- activeCallback = null
417
- isInstalled = false
418
- }
419
-
420
- // ─── Network Buffer ──────────────────────────────────────────────────────────
421
-
422
- export class NetworkBuffer {
423
- private entries: Map<string, NetworkEntry> = new Map()
424
- private order: string[] = []
425
- private maxSize: number
426
-
427
- constructor(maxSize = DEFAULT_MAX_NETWORK_ENTRIES) {
428
- this.maxSize = maxSize
429
- }
430
-
431
- /**
432
- * Upsert a network entry. If the entry already exists (same id),
433
- * update it in place (e.g., pending → completed). Otherwise add new.
434
- */
435
- upsert(entry: NetworkEntry): void {
436
- if (this.entries.has(entry.id)) {
437
- // Update existing entry (pending → completed/error)
438
- this.entries.set(entry.id, entry)
439
- } else {
440
- // Add new entry at the front
441
- this.order.unshift(entry.id)
442
- this.entries.set(entry.id, entry)
443
-
444
- // Evict oldest if over capacity
445
- while (this.order.length > this.maxSize) {
446
- const oldId = this.order.pop()
447
- if (oldId) this.entries.delete(oldId)
448
- }
449
- }
450
- }
451
-
452
- getAll(): NetworkEntry[] {
453
- return this.order.map(id => this.entries.get(id)!).filter(Boolean)
454
- }
455
-
456
- clear(): void {
457
- this.entries.clear()
458
- this.order = []
459
- }
460
-
461
- get hasErrors(): boolean {
462
- return this.order.some(id => this.entries.get(id)?.state === NetworkState.error)
463
- }
464
-
465
- get size(): number {
466
- return this.order.length
467
- }
468
- }