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.
- package/package.json +3 -3
- package/src/Backstage.tsx +0 -227
- package/src/ThemeContext.tsx +0 -38
- package/src/bug-report.ts +0 -184
- package/src/components/BackstagePanel.tsx +0 -349
- package/src/components/BugReportComposer.tsx +0 -559
- package/src/components/FlagsTab.tsx +0 -207
- package/src/components/FloatingPill.tsx +0 -247
- package/src/components/InfoTab.tsx +0 -231
- package/src/components/JsonTreeView.tsx +0 -239
- package/src/components/LogItem.tsx +0 -153
- package/src/components/LogsTab.tsx +0 -215
- package/src/components/NetworkItem.tsx +0 -425
- package/src/components/NetworkTab.tsx +0 -239
- package/src/components/StorageTab.tsx +0 -643
- package/src/components/TabBar.tsx +0 -154
- package/src/constants.ts +0 -207
- package/src/index.ts +0 -37
- package/src/log-interceptor.ts +0 -148
- package/src/network-interceptor.ts +0 -468
- package/src/types.ts +0 -280
- package/src/utils/formatTimestamp.ts +0 -22
- package/src/utils/stringify.ts +0 -90
|
@@ -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
|
-
}
|