reqwise-core 1.0.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.
@@ -0,0 +1,574 @@
1
+ import { getEntries, subscribe } from './storage'
2
+ import ReqwiseClient from './client'
3
+ import panel from './panel'
4
+ import i18n from './i18n'
5
+
6
+ const ENDPOINTS_KEY = 'reqwise_endpoints_v1'
7
+
8
+ function safeParseJSON(s: string | null) {
9
+ try { return s ? JSON.parse(s) : null } catch (e) { return null }
10
+ }
11
+
12
+ function saveEndpoints(obj: any) {
13
+ try { localStorage.setItem(ENDPOINTS_KEY, JSON.stringify(obj)) } catch (e) { }
14
+ }
15
+
16
+ function loadEndpoints() {
17
+ return safeParseJSON(localStorage.getItem(ENDPOINTS_KEY)) || {}
18
+ }
19
+
20
+ function normalizePath(urlStr: string) {
21
+ try {
22
+ const u = new URL(urlStr, window.location.origin)
23
+ return { pathname: u.pathname, search: u.search, origin: u.origin }
24
+ } catch (e) {
25
+ return { pathname: urlStr, search: '', origin: '' }
26
+ }
27
+ }
28
+
29
+ function inferType(val: string) {
30
+ if (val === 'true' || val === 'false') return 'boolean'
31
+ if (!isNaN(Number(val)) && val.trim() !== '') return 'number'
32
+ return 'string'
33
+ }
34
+
35
+ function updateEndpointsFromEntry(entry: any) {
36
+ try {
37
+ if (!entry || !entry.url) return
38
+ const { pathname, search } = normalizePath(entry.url)
39
+ const endpoints = loadEndpoints()
40
+ const key = pathname
41
+ if (!endpoints[key]) endpoints[key] = { path: key, methods: {}, params: {}, examples: [], count: 0, lastSeen: 0 }
42
+ const meta = endpoints[key]
43
+ meta.count = (meta.count || 0) + 1
44
+ meta.lastSeen = Date.now()
45
+ const m = (entry.method || 'GET').toUpperCase()
46
+ meta.methods[m] = (meta.methods[m] || 0) + 1
47
+ if (search) {
48
+ const sp = new URLSearchParams(search)
49
+ for (const [k, v] of sp.entries()) {
50
+ const t = inferType(v)
51
+ if (!meta.params[k]) meta.params[k] = { types: {}, examples: [] }
52
+ meta.params[k].types[t] = (meta.params[k].types[t] || 0) + 1
53
+ if (!meta.params[k].examples.includes(v)) meta.params[k].examples.push(v)
54
+ }
55
+ }
56
+ const example = entry.url + (entry.method ? ` (${entry.method})` : '')
57
+ if (!meta.examples.includes(example)) meta.examples.push(example)
58
+ endpoints[key] = meta
59
+ saveEndpoints(endpoints)
60
+ } catch (e) {
61
+ console.error('[reqwise] updateEndpointsFromEntry', e)
62
+ }
63
+ }
64
+
65
+ function renderTabs(container: HTMLElement, active: string, onChange: (t: string)=>void) {
66
+ // container = .rq-body, which has .rq-tabs and .rq-main children
67
+ const tabsEl = container.querySelector('.rq-tabs') as HTMLElement
68
+ const mainEl = container.querySelector('.rq-main') as HTMLElement
69
+
70
+ if (!tabsEl || !mainEl) {
71
+ console.warn('[reqwise] rq-tabs or rq-main container missing')
72
+ return
73
+ }
74
+
75
+ // Always (re)render tabs so labels update when language changes
76
+ const tabsHtml = `
77
+ <button data-tab="current" class="rq-tab ${active==='current'?'active':''}" data-tabname="current">${i18n.t('tab_current')}</button>
78
+ <button data-tab="history" class="rq-tab ${active==='history'?'active':''}" data-tabname="history">${i18n.t('tab_history')}</button>
79
+ <button data-tab="send" class="rq-tab ${active==='send'?'active':''}" data-tabname="send">${i18n.t('tab_send')}</button>
80
+ <button data-tab="endpoints" class="rq-tab ${active==='endpoints'?'active':''}" data-tabname="endpoints">${i18n.t('tab_endpoints')}</button>
81
+ `
82
+ tabsEl.innerHTML = tabsHtml
83
+
84
+ const tabs = Array.from(tabsEl.querySelectorAll('.rq-tab')) as HTMLElement[]
85
+ for (let i = 0; i < tabs.length; i++) {
86
+ const b = tabs[i]
87
+ b.addEventListener('click', (ev)=>{
88
+ const t = (ev.currentTarget as HTMLElement).getAttribute('data-tabname') || 'current'
89
+ // toggle active styles
90
+ const tabs2 = Array.from(tabsEl.querySelectorAll('.rq-tab')) as HTMLElement[]
91
+ for (let j = 0; j < tabs2.length; j++) { tabs2[j].classList.remove('active'); }
92
+ (ev.currentTarget as HTMLElement).classList.add('active')
93
+ onChange(t)
94
+ })
95
+ }
96
+ }
97
+
98
+ function renderCurrent(container: HTMLElement) {
99
+ const currentPath = window.location.pathname
100
+ const currentHref = window.location.href
101
+ const entries = getEntries().filter((e:any)=> {
102
+ const page = e.page || ''
103
+ return page.includes(currentPath) || page.includes(currentHref) || page === currentPath
104
+ })
105
+
106
+ const main = container.querySelector('.rq-main') as HTMLElement
107
+
108
+ if (!entries.length) {
109
+ main.innerHTML = `
110
+ <div class="rq-empty">
111
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
112
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
113
+ </svg>
114
+ <p>${i18n.t('no_requests_page')}</p>
115
+ </div>
116
+ `
117
+ return
118
+ }
119
+
120
+ const html = entries.map((e:any) => formatCard(e)).join('')
121
+ main.innerHTML = html
122
+ }
123
+
124
+ function formatCard(e: any) {
125
+ const statusBadge = getStatusBadge(e.status)
126
+ const sourceClass = e.source === 'manual' ? 'manual' : ''
127
+ const errorClass = e.error ? 'error' : ''
128
+ const duration = e.duration ? `${e.duration}ms` : '—'
129
+ const statusText = e.statusText ? e.statusText : ''
130
+
131
+ return `
132
+ <div class="rq-card">
133
+ <div class="rq-card-header">
134
+ <h3 class="rq-card-title">${e.method || 'GET'} ${extractPath(e.url)}</h3>
135
+ ${e.source === 'manual' ? ('<span class="rq-badge">' + i18n.t('manual_badge') + '</span>') : ''}
136
+ ${statusBadge}
137
+ </div>
138
+
139
+ <div class="rq-meta">
140
+ <div class="rq-meta-item">
141
+ <span class="rq-meta-label">${i18n.t('form_url')}:</span>
142
+ <code class="rq-meta-code">${escapeHtml(e.url)}</code>
143
+ </div>
144
+ ${e.status ? `
145
+ <div class="rq-meta-item">
146
+ <span class="rq-meta-label">${i18n.t('status')}:</span>
147
+ <code class="rq-meta-status ${getStatusClass(e.status)}">${e.status}${statusText ? ' ' + statusText : ''}</code>
148
+ </div>
149
+ ` : ''}
150
+ <div class="rq-meta-item">
151
+ <span class="rq-meta-label">${i18n.t('duration')}:</span>
152
+ <code class="rq-meta-code">${duration}</code>
153
+ </div>
154
+ <div class="rq-meta-item">
155
+ <span class="rq-meta-label">${i18n.t('time')}:</span>
156
+ <code class="rq-meta-code">${new Date(e.timestamp).toLocaleTimeString()}</code>
157
+ </div>
158
+ </div>
159
+
160
+ ${e.requestBody && ['POST', 'PUT', 'PATCH'].includes(e.method || '') ? `
161
+ <div class="rq-section">
162
+ <div class="rq-section-label">${i18n.t('request')} ${i18n.t('body')}</div>
163
+ <div class="rq-json">${highlightJSON(e.requestBody)}</div>
164
+ </div>
165
+ ` : ''}
166
+
167
+ ${e.error ? `
168
+ <div class="rq-section">
169
+ <div class="rq-section-label">${i18n.t('error_label')}</div>
170
+ <div style="background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; padding: 12px; color: #ef4444; font-size: 13px;">
171
+ <strong>${e.error.message || 'Unknown error'}</strong>
172
+ ${e.error.stack ? `<pre style="margin-top: 8px; font-size: 12px; white-space: pre-wrap;">${escapeHtml(e.error.stack)}</pre>` : ''}
173
+ </div>
174
+ </div>
175
+ ` : ''}
176
+
177
+ ${e.responseBody !== undefined ? `
178
+ <div class="rq-section">
179
+ <div class="rq-section-label">${i18n.t('response_label')}</div>
180
+ <div class="rq-json">${highlightJSON(e.responseBody)}</div>
181
+ </div>
182
+ ` : ''}
183
+ </div>
184
+ `
185
+ }
186
+
187
+ function highlightJSON(obj: any): string {
188
+ if (!obj) return 'null'
189
+ try {
190
+ const json = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2)
191
+ return highlightJSONString(json)
192
+ } catch (e) {
193
+ return escapeHtml(String(obj))
194
+ }
195
+ }
196
+
197
+ function highlightJSONString(str: string): string {
198
+ return str
199
+ .split('\n')
200
+ .map(line => {
201
+ // Key: "someKey":
202
+ const keyMatch = line.match(/^(\s*)("[\w\s]+")(:\s*)(.*)/)
203
+ if (keyMatch) {
204
+ const [, indent, key, colon, rest] = keyMatch
205
+ const keyHTML = `<span class="rq-json-key">${key}</span>`
206
+ const colonHTML = `<span class="rq-json-bracket">${colon}</span>`
207
+ const restHTML = highlightValue(rest.trim())
208
+ return `<div>${escapeHtml(indent)}<span>${keyHTML}</span>${colonHTML}${restHTML}</div>`
209
+ }
210
+ return `<div>${highlightValue(line)}</div>`
211
+ })
212
+ .join('')
213
+ }
214
+
215
+ function highlightValue(str: string): string {
216
+ const trimmed = str.trim()
217
+
218
+ // String
219
+ if (trimmed.startsWith('"')) {
220
+ return `<span class="rq-json-string">${escapeHtml(str)}</span>`
221
+ }
222
+ // Number
223
+ if (/^-?\d+(\.\d+)?[,]?$/.test(trimmed)) {
224
+ return `<span class="rq-json-number">${escapeHtml(str)}</span>`
225
+ }
226
+ // Boolean
227
+ if (/^(true|false)[,]?$/.test(trimmed)) {
228
+ return `<span class="rq-json-boolean">${escapeHtml(str)}</span>`
229
+ }
230
+ // Null
231
+ if (/^null[,]?$/.test(trimmed)) {
232
+ return `<span class="rq-json-null">${escapeHtml(str)}</span>`
233
+ }
234
+ // Brackets
235
+ return `<span class="rq-json-bracket">${escapeHtml(str)}</span>`
236
+ }
237
+
238
+ function getStatusBadge(status?: number): string {
239
+ if (!status) return ''
240
+ if (status >= 200 && status < 300) {
241
+ return `<span class="rq-badge success">${i18n.t('status_success')}</span>`
242
+ } else if (status >= 400 && status < 500) {
243
+ return `<span class="rq-badge error">${i18n.t('status_client_error')}</span>`
244
+ } else if (status >= 500) {
245
+ return `<span class="rq-badge error">${i18n.t('status_server_error')}</span>`
246
+ }
247
+ return ''
248
+ }
249
+
250
+ function getStatusClass(status: number): string {
251
+ if (status >= 200 && status < 300) return 'status-2xx'
252
+ if (status >= 400 && status < 500) return 'status-4xx'
253
+ if (status >= 500) return 'status-5xx'
254
+ return ''
255
+ }
256
+
257
+ function extractPath(url: string): string {
258
+ try {
259
+ const u = new URL(url, window.location.origin)
260
+ return u.pathname + u.search
261
+ } catch (e) {
262
+ return url
263
+ }
264
+ }
265
+
266
+ function renderHistory(container: HTMLElement) {
267
+ const entries = getEntries()
268
+ const main = container.querySelector('.rq-main') as HTMLElement
269
+
270
+ if (!entries.length) {
271
+ main.innerHTML = `
272
+ <div class="rq-empty">
273
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
274
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
275
+ </svg>
276
+ <p>${i18n.t('no_history_yet')}</p>
277
+ </div>
278
+ `
279
+ return
280
+ }
281
+
282
+ main.innerHTML = `
283
+ <div class="rq-section" style="margin-bottom: 16px;">
284
+ <input type="text" class="rq-filter rq-form-input" placeholder="${i18n.t('filter_placeholder')}" style="width: 100%;" />
285
+ </div>
286
+ <div class="rq-list"></div>
287
+ `
288
+
289
+ function renderList(items: any[]) {
290
+ const list = main.querySelector('.rq-list') as HTMLElement
291
+ if (!items.length) {
292
+ list.innerHTML = `<div class="rq-empty" style="min-height: 200px;"><p>${i18n.t('no_matching_requests')}</p></div>`
293
+ return
294
+ }
295
+ list.innerHTML = items.map((e:any) => formatHistoryCard(e)).join('')
296
+ }
297
+
298
+ const filter = main.querySelector('.rq-filter') as HTMLInputElement
299
+ filter.addEventListener('input', () => {
300
+ const q = filter.value.toLowerCase()
301
+ const filtered = entries.filter((e:any) =>
302
+ (e.method || '').toLowerCase().includes(q) ||
303
+ (e.status || '').toString().includes(q) ||
304
+ (e.url || '').toLowerCase().includes(q) ||
305
+ (e.page || '').toLowerCase().includes(q)
306
+ )
307
+ renderList(filtered)
308
+ })
309
+
310
+ renderList(entries)
311
+ }
312
+
313
+ function formatHistoryCard(e: any): string {
314
+ const statusBadge = getStatusBadge(e.status)
315
+ const duration = e.duration ? `${e.duration}ms` : '—'
316
+ const statusText = e.statusText ? e.statusText : ''
317
+
318
+ return `
319
+ <div class="rq-card" style="border: 1px solid var(--rq-border); border-radius: 8px; padding: 16px; margin-bottom: 12px;">
320
+ <div class="rq-card-header">
321
+ <h4 class="rq-card-title">${e.method || 'GET'} ${extractPath(e.url)}</h4>
322
+ ${statusBadge}
323
+ ${e.source === 'manual' ? ('<span class="rq-badge">' + i18n.t('manual_badge') + '</span>') : ''}
324
+ </div>
325
+
326
+ <div class="rq-meta" style="gap: 12px; margin-top: 8px;">
327
+ <div class="rq-meta-item">
328
+ <span class="rq-meta-label">${i18n.t('endpoint_label')}:</span>
329
+ <code class="rq-meta-code">${escapeHtml(e.url)}</code>
330
+ </div>
331
+ ${e.status ? `
332
+ <div class="rq-meta-item">
333
+ <span class="rq-meta-label">${i18n.t('status')}:</span>
334
+ <code class="rq-meta-status ${getStatusClass(e.status)}">${e.status}</code>
335
+ </div>
336
+ ` : ''}
337
+ <div class="rq-meta-item">
338
+ <span class="rq-meta-label">${i18n.t('time')}:</span>
339
+ <code class="rq-meta-code">${new Date(e.timestamp).toLocaleTimeString()}</code>
340
+ </div>
341
+ </div>
342
+ </div>
343
+ `
344
+ }
345
+
346
+ function renderSend(container: HTMLElement) {
347
+ const main = container.querySelector('.rq-main') as HTMLElement
348
+ main.innerHTML = `
349
+ <form class="rq-form" style="display: flex; flex-direction: column; gap: 16px;">
350
+
351
+ <div class="rq-form-group">
352
+ <label class="rq-form-label">${i18n.t('form_method')}</label>
353
+ <select name="method" class="rq-form-select">
354
+ <option>GET</option>
355
+ <option>POST</option>
356
+ <option>PUT</option>
357
+ <option>PATCH</option>
358
+ <option>DELETE</option>
359
+ </select>
360
+ </div>
361
+
362
+ <div class="rq-form-group">
363
+ <label class="rq-form-label">${i18n.t('form_url')}</label>
364
+ <input type="text" name="url" class="rq-form-input" placeholder="https://api.example.com/users" />
365
+ </div>
366
+
367
+ <div class="rq-form-group">
368
+ <label class="rq-form-label">${i18n.t('form_headers')}</label>
369
+ <textarea name="headers" class="rq-form-textarea" placeholder='{"Authorization": "Bearer token", "Content-Type": "application/json"}'></textarea>
370
+ </div>
371
+
372
+ <div class="rq-form-group">
373
+ <label class="rq-form-label">${i18n.t('form_body')}</label>
374
+ <textarea name="body" class="rq-form-textarea" placeholder='{"name": "John", "email": "john@example.com"}'></textarea>
375
+ </div>
376
+
377
+ <div style="display: flex; gap: 8px;">
378
+ <button type="button" class="rq-btn primary" style="flex: 1;">${i18n.t('send_request')}</button>
379
+ <button type="button" class="rq-btn" style="flex: 1;">${i18n.t('clear_form')}</button>
380
+ </div>
381
+
382
+ <div class="rq-response" style="margin-top: 16px;"></div>
383
+ </form>
384
+ `
385
+
386
+ const form = main.querySelector('.rq-form') as HTMLFormElement
387
+ const sendBtn = form.querySelector('button:first-of-type') as HTMLElement
388
+ const clearBtn = form.querySelector('button:last-of-type') as HTMLElement
389
+ const responseDiv = form.querySelector('.rq-response') as HTMLElement
390
+
391
+ clearBtn.addEventListener('click', (e) => {
392
+ e.preventDefault()
393
+ form.reset()
394
+ responseDiv.innerHTML = ''
395
+ })
396
+
397
+ sendBtn.addEventListener('click', async (e) => {
398
+ e.preventDefault()
399
+ const method = (form.elements.namedItem('method') as HTMLSelectElement).value
400
+ const url = (form.elements.namedItem('url') as HTMLInputElement).value
401
+ const headersStr = (form.elements.namedItem('headers') as HTMLTextAreaElement).value
402
+ const bodyStr = (form.elements.namedItem('body') as HTMLTextAreaElement).value
403
+
404
+ if (!url) {
405
+ responseDiv.innerHTML = '<div style="color: #ef4444; padding: 12px; background: rgba(239,68,68,0.1); border-radius: 6px;">' + i18n.t('enter_url_warning') + '</div>'
406
+ return
407
+ }
408
+
409
+ try {
410
+ responseDiv.innerHTML = '<div style="color: var(--rq-fg-secondary); padding: 12px;">' + i18n.t('loading') + '</div>'
411
+
412
+ let headers: any = {}
413
+ try {
414
+ headers = headersStr ? JSON.parse(headersStr) : {}
415
+ } catch (e) {}
416
+
417
+ let body: any = undefined
418
+ if (method !== 'GET' && bodyStr) {
419
+ try {
420
+ body = JSON.parse(bodyStr)
421
+ } catch (e) {
422
+ body = bodyStr
423
+ }
424
+ }
425
+
426
+ const res = await ReqwiseClient.fetch(url, {
427
+ method,
428
+ headers,
429
+ body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined
430
+ })
431
+
432
+ const text = await res.text()
433
+ let displayText = text
434
+ try {
435
+ displayText = JSON.stringify(JSON.parse(text), null, 2)
436
+ } catch (e) {}
437
+
438
+ const statusClass = res.status >= 200 && res.status < 300 ? 'status-2xx' : 'status-4xx'
439
+ responseDiv.innerHTML = `
440
+ <div class="rq-section">
441
+ <div class="rq-section-label">${i18n.t('response_label')}</div>
442
+ <div style="display: flex; gap: 8px; margin-bottom: 12px;">
443
+ <code class="rq-meta-status ${statusClass}">${res.status} ${res.statusText}</code>
444
+ <code class="rq-meta-code">${text.length} ${i18n.t('bytes')}</code>
445
+ </div>
446
+ <div class="rq-json">${highlightJSON(displayText)}</div>
447
+ </div>
448
+ `
449
+ } catch (err: any) {
450
+ responseDiv.innerHTML = `
451
+ <div class="rq-section">
452
+ <div class="rq-section-label">${i18n.t('error_label')}</div>
453
+ <div style="background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; padding: 12px; color: #ef4444; font-size: 13px;">
454
+ <strong>${err.message || i18n.t('request_failed')}</strong>
455
+ </div>
456
+ </div>
457
+ `
458
+ }
459
+ })
460
+ }
461
+
462
+ function escapeHtml(s: string) {
463
+ return s.replace(/[&<>"']/g, (c)=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c] || c))
464
+ }
465
+
466
+ function renderEndpoints(container: HTMLElement) {
467
+ const main = container.querySelector('.rq-main') as HTMLElement
468
+ const endpoints = loadEndpoints()
469
+ const keys = Object.keys(endpoints).sort((a, b) => (endpoints[b].lastSeen || 0) - (endpoints[a].lastSeen || 0))
470
+
471
+ if (!keys.length) {
472
+ main.innerHTML = `
473
+ <div class="rq-empty">
474
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
475
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
476
+ </svg>
477
+ <p>${i18n.t('no_endpoints')}</p>
478
+ </div>
479
+ `
480
+ return
481
+ }
482
+
483
+ const html = keys.map(k => {
484
+ const e = endpoints[k]
485
+ const methods = Object.keys(e.methods || {})
486
+ .map(m => `<code class="rq-meta-code">${m}(${e.methods[m]})</code>`)
487
+ .join('')
488
+
489
+ const params = Object.keys(e.params || {})
490
+ .map(p => {
491
+ const types = Object.keys(e.params[p].types || {}).join(', ')
492
+ const examples = (e.params[p].examples || []).slice(0, 2).join(', ')
493
+ return `
494
+ <div style="margin-bottom: 8px;">
495
+ <code class="rq-meta-code">${p}</code>
496
+ <span style="color: var(--rq-fg-secondary); font-size: 12px;">• ${types}${examples ? ' • ' + examples : ''}</span>
497
+ </div>
498
+ `
499
+ })
500
+ .join('')
501
+
502
+ const examples = (e.examples || []).slice(0, 3).map((ex: any) => `<code class="rq-meta-code">${escapeHtml(ex)}</code>`).join('<br/>')
503
+
504
+ return `
505
+ <div class="rq-card" style="border: 1px solid var(--rq-border); border-radius: 8px; padding: 16px; margin-bottom: 12px;">
506
+ <div class="rq-card-header">
507
+ <h4 class="rq-card-title">${escapeHtml(e.path)}</h4>
508
+ <span style="background: var(--rq-bg-secondary); padding: 4px 12px; border-radius: 4px; font-size: 12px; color: var(--rq-fg-secondary);">${e.count || 0} ${i18n.t('hits')}</span>
509
+ </div>
510
+
511
+ <div class="rq-section" style="margin-top: 12px;">
512
+ <div class="rq-section-label">${i18n.t('methods')}</div>
513
+ <div style="display: flex; gap: 8px; flex-wrap: wrap;">${methods}</div>
514
+ </div>
515
+
516
+ ${params ? `
517
+ <div class="rq-section" style="margin-top: 12px;">
518
+ <div class="rq-section-label">${i18n.t('parameters')}</div>
519
+ <div>${params}</div>
520
+ </div>
521
+ ` : ''}
522
+
523
+ <div class="rq-section" style="margin-top: 12px;">
524
+ <div class="rq-section-label">${i18n.t('examples')}</div>
525
+ <div>${examples || ('<span style="color: var(--rq-fg-secondary);">' + i18n.t('no_examples') + '</span>')}</div>
526
+ </div>
527
+
528
+ <div style="margin-top: 12px; font-size: 12px; color: var(--rq-fg-secondary);">
529
+ ${i18n.t('last_seen')}: ${new Date(e.lastSeen).toLocaleTimeString()}
530
+ </div>
531
+ </div>
532
+ `
533
+ }).join('')
534
+
535
+ main.innerHTML = html
536
+ }
537
+
538
+ export function renderer(container: HTMLElement) {
539
+ try {
540
+ renderTabs(container, 'current', (tab)=>{
541
+ if (tab === 'current') renderCurrent(container)
542
+ else if (tab === 'history') renderHistory(container)
543
+ else if (tab === 'send') renderSend(container)
544
+ else if (tab === 'endpoints') renderEndpoints(container)
545
+ })
546
+ // Note: initial render happens via subscription callback below (subscribe calls immediately)
547
+
548
+ // process existing entries into endpoints
549
+ try { getEntries().forEach((e:any)=> updateEndpointsFromEntry(e)) } catch(e){}
550
+
551
+ // subscribe to storage updates — this immediately calls callback with current snapshot
552
+ const unsub = subscribe((list:any[])=>{
553
+ // update endpoints for latest
554
+ if (list && list.length) updateEndpointsFromEntry(list[0])
555
+ // re-render the currently active tab
556
+ const panelEl = document.getElementById('reqwise-panel')
557
+ if (!panelEl) return
558
+ const active = panelEl.querySelector('.rq-tab.active') as HTMLElement | null
559
+ if (!active) return
560
+ const tabName = active.getAttribute('data-tabname')
561
+ if (tabName === 'current') renderCurrent(panelEl)
562
+ else if (tabName === 'history') renderHistory(panelEl)
563
+ else if (tabName === 'endpoints') renderEndpoints(panelEl)
564
+ })
565
+
566
+ ;(container as any)._reqwise_unsub = unsub
567
+ } catch (e) {
568
+ console.error('[reqwise] renderer error', e)
569
+ }
570
+ }
571
+
572
+ // auto-register removed: ReqwiseDevTools explicitly calls panel.setRenderer(renderer)
573
+
574
+ export default renderer
package/src/storage.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { ReqwiseEntry, ReqwiseConfig } from './types'
2
+
3
+ const STORAGE_KEY = 'reqwise_history_v1'
4
+
5
+ let config: ReqwiseConfig = {
6
+ maxItems: 200,
7
+ persistHistory: true,
8
+ }
9
+
10
+ let memory: ReqwiseEntry[] = []
11
+ const subscribers: Array<(items: ReqwiseEntry[]) => void> = []
12
+
13
+ function canUseLocalStorage() {
14
+ try {
15
+ return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
16
+ } catch (e) {
17
+ return false
18
+ }
19
+ }
20
+
21
+ function notify() {
22
+ const snapshot = memory.slice()
23
+ for (const s of subscribers) s(snapshot)
24
+ }
25
+
26
+ function load() {
27
+ if (!config.persistHistory) return
28
+ if (!canUseLocalStorage()) return
29
+ try {
30
+ const raw = window.localStorage.getItem(STORAGE_KEY)
31
+ if (!raw) return
32
+ const parsed = JSON.parse(raw) as ReqwiseEntry[]
33
+ memory = Array.isArray(parsed) ? parsed.slice(-config.maxItems!) : []
34
+ } catch (e) {
35
+ memory = []
36
+ }
37
+ }
38
+
39
+ function persist() {
40
+ if (!config.persistHistory) return
41
+ if (!canUseLocalStorage()) return
42
+ try {
43
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(memory))
44
+ } catch (e) {
45
+ // ignore
46
+ }
47
+ }
48
+
49
+ export function initStorage(cfg?: Partial<ReqwiseConfig>) {
50
+ config = { ...config, ...(cfg || {}) } as ReqwiseConfig
51
+ if (config.persistHistory) load()
52
+ }
53
+
54
+ export function saveEntry(entry: ReqwiseEntry) {
55
+ memory.push(entry)
56
+ if (config.maxItems && memory.length > config.maxItems) {
57
+ memory = memory.slice(-config.maxItems)
58
+ }
59
+ persist()
60
+ notify()
61
+ }
62
+
63
+ export function getEntries(): ReqwiseEntry[] {
64
+ return memory.slice().reverse()
65
+ }
66
+
67
+ export function clearEntries() {
68
+ memory = []
69
+ if (canUseLocalStorage()) {
70
+ try {
71
+ window.localStorage.removeItem(STORAGE_KEY)
72
+ } catch (e) {
73
+ // ignore
74
+ }
75
+ }
76
+ notify()
77
+ }
78
+
79
+ export function subscribe(fn: (items: ReqwiseEntry[]) => void) {
80
+ subscribers.push(fn)
81
+ // call immediately with current snapshot
82
+ fn(memory.slice().reverse())
83
+ return () => {
84
+ const idx = subscribers.indexOf(fn)
85
+ if (idx !== -1) subscribers.splice(idx, 1)
86
+ }
87
+ }
88
+
89
+ export function replaceAll(entries: ReqwiseEntry[]) {
90
+ memory = entries.slice(-config.maxItems!)
91
+ persist()
92
+ notify()
93
+ }
94
+
95
+ export default {
96
+ initStorage,
97
+ saveEntry,
98
+ getEntries,
99
+ clearEntries,
100
+ subscribe,
101
+ replaceAll,
102
+ }