@kfiross44/valtio-inspector 0.9.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,8 @@
1
+ # Changesets
2
+
3
+ Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4
+ with multi-package repos, or single-package repos to help you version and publish your code. You can
5
+ find the full documentation for it [in our repository](https://github.com/changesets/changesets).
6
+
7
+ We have a quick list of common questions to get you started engaging with this project in
8
+ [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "fixed": [],
6
+ "linked": [],
7
+ "access": "restricted",
8
+ "baseBranch": "main",
9
+ "updateInternalDependencies": "patch",
10
+ "ignore": []
11
+ }
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # Valtio Inspector
2
+
3
+ A dev-only state inspector for [Valtio](https://github.com/pmndrs/valtio) with live WebSocket updates, multiple store support, history, and time travel.
4
+
5
+ ## Features
6
+
7
+ - 🔴 **Live WebSocket updates** — no polling
8
+ - 🗂 **Multiple stores** — sidebar navigation
9
+ - 🌳 **Collapsible JSON tree** — expand/collapse nodes
10
+ - 📜 **History log** — every change tracked with timestamps
11
+ - ⏪ **Time travel** — click any history entry to inspect that snapshot
12
+ - 🔍 **Diff view** — see exactly what changed between snapshots
13
+ - ⚡ **Auto-reconnect** — if the server restarts
14
+
15
+ ---
16
+
17
+ ## Setup
18
+
19
+ ### 1. Install & run the inspector server
20
+
21
+ ```bash
22
+ cd valtio-inspector
23
+ npm install
24
+ npm run dev
25
+ # → http://localhost:7777
26
+ ```
27
+
28
+ ### 2. Instrument your stores
29
+
30
+ Copy `instrument/attachInspector.ts` into your app (requires `valtio` installed).
31
+
32
+ ```ts
33
+ import { proxy } from 'valtio'
34
+ import { attachInspector } from './attachInspector'
35
+
36
+ export const authStore = proxy({
37
+ user: { id: 1, name: 'kfir' },
38
+ isLoggedIn: false
39
+ })
40
+
41
+ export const uiStore = proxy({
42
+ darkMode: true,
43
+ sidebarOpen: false
44
+ })
45
+
46
+ // Only in dev!
47
+ if (process.env.NODE_ENV !== 'production') {
48
+ attachInspector(authStore, { name: 'auth' })
49
+ attachInspector(uiStore, { name: 'ui' })
50
+ }
51
+ ```
52
+
53
+ `attachInspector` returns a cleanup function (for React `useEffect`, etc.):
54
+ ```ts
55
+ const cleanup = attachInspector(store, { name: 'myStore' })
56
+ // later:
57
+ cleanup()
58
+ ```
59
+
60
+ ### Options
61
+
62
+ ```ts
63
+ attachInspector(store, {
64
+ name: 'myStore', // required — display name
65
+ debounce: 100, // optional — ms debounce (default: 100)
66
+ inspectorUrl: 'http://localhost:7777' // optional — if running elsewhere
67
+ })
68
+ ```
69
+
70
+ ---
71
+
72
+ ## API
73
+
74
+ The server exposes these endpoints (used internally by the UI):
75
+
76
+ | Method | Path | Description |
77
+ |--------|------|-------------|
78
+ | `POST` | `/state` | Push a store update |
79
+ | `GET` | `/state` | Get the current full state tree |
80
+ | `DELETE` | `/state` | Clear all state + history |
81
+ | `GET` | `/history` | Get full history log |
82
+ | `GET` | `/history/:id` | Get a specific history snapshot |
83
+
84
+ ---
85
+
86
+ ## Building for production (server)
87
+
88
+ ```bash
89
+ npm run build
90
+ npm start
91
+ ```
92
+
93
+ ---
94
+
95
+ ## ⚠️ Security
96
+
97
+ - **Never expose port 7777 publicly.** This is a dev-only tool.
98
+ - Always gate `attachInspector` behind `process.env.NODE_ENV !== 'production'`.
99
+ - Do not run the inspector server in production builds.
package/client/app.js ADDED
@@ -0,0 +1,329 @@
1
+ /* ── State ── */
2
+ let stateTree = {}
3
+ let historyLog = []
4
+ let selectedStore = null
5
+ let selectedHistoryId = null
6
+ let activeTab = 'tree'
7
+ let collapsedPaths = new Set()
8
+ let lastDiff = {}
9
+
10
+ /* ── WebSocket ── */
11
+ const wsUrl = `ws://${location.host}`
12
+ let ws
13
+
14
+ function connect() {
15
+ ws = new WebSocket(wsUrl)
16
+
17
+ ws.onopen = () => {
18
+ setStatus(true)
19
+ }
20
+
21
+ ws.onclose = () => {
22
+ setStatus(false)
23
+ setTimeout(connect, 2000)
24
+ }
25
+
26
+ ws.onerror = () => {
27
+ setStatus(false)
28
+ }
29
+
30
+ ws.onmessage = (event) => {
31
+ const msg = JSON.parse(event.data)
32
+
33
+ if (msg.type === 'INIT') {
34
+ stateTree = msg.state || {}
35
+ historyLog = msg.history || []
36
+ renderAll()
37
+ }
38
+
39
+ if (msg.type === 'STATE_UPDATE') {
40
+ stateTree = msg.state
41
+ if (msg.entry) {
42
+ historyLog.push(msg.entry)
43
+ if (historyLog.length > 100) historyLog.shift()
44
+ if (msg.entry.store === selectedStore && msg.entry.diff) {
45
+ lastDiff[selectedStore] = msg.entry.diff
46
+ }
47
+ }
48
+ flashStore(msg.entry?.store)
49
+ renderAll()
50
+ }
51
+
52
+ if (msg.type === 'CLEAR') {
53
+ stateTree = {}
54
+ historyLog = []
55
+ selectedStore = null
56
+ selectedHistoryId = null
57
+ lastDiff = {}
58
+ renderAll()
59
+ }
60
+ }
61
+ }
62
+
63
+ function setStatus(connected) {
64
+ const pill = document.getElementById('status-pill')
65
+ const text = document.getElementById('status-text')
66
+ pill.className = 'status-pill' + (connected ? ' connected' : '')
67
+ text.textContent = connected ? 'connected' : 'disconnected'
68
+ }
69
+
70
+ /* ── Render all ── */
71
+ function renderAll() {
72
+ renderSidebar()
73
+ renderMain()
74
+ renderHistory()
75
+ }
76
+
77
+ /* ── Sidebar ── */
78
+ const storeColors = ['#7c6af7', '#56e0a0', '#f7a26a', '#f06c8a', '#7ec8e3', '#c8e37e']
79
+ function storeColor(name) {
80
+ let hash = 0
81
+ for (let c of name) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff
82
+ return storeColors[Math.abs(hash) % storeColors.length]
83
+ }
84
+
85
+ function renderSidebar() {
86
+ const list = document.getElementById('store-list')
87
+ const stores = Object.keys(stateTree)
88
+
89
+ if (stores.length === 0) {
90
+ list.innerHTML = ''
91
+ return
92
+ }
93
+
94
+ list.innerHTML = stores.map(name => {
95
+ const color = storeColor(name)
96
+ const initial = name[0].toUpperCase()
97
+ const active = name === selectedStore ? ' active' : ''
98
+ return `
99
+ <div class="store-item${active}" onclick="selectStore('${name}')">
100
+ <div class="store-icon" style="color:${color};border:1px solid ${color}33">${initial}</div>
101
+ <span class="store-name">${name}</span>
102
+ <div class="store-flash" id="flash-${name}"></div>
103
+ </div>`
104
+ }).join('')
105
+ }
106
+
107
+ function flashStore(name) {
108
+ if (!name) return
109
+ setTimeout(() => {
110
+ const el = document.getElementById(`flash-${name}`)
111
+ if (el) {
112
+ el.parentElement.classList.remove('flash')
113
+ void el.parentElement.offsetWidth
114
+ el.parentElement.classList.add('flash')
115
+ }
116
+ }, 0)
117
+ }
118
+
119
+ function selectStore(name) {
120
+ selectedStore = name
121
+ selectedHistoryId = null
122
+ renderAll()
123
+ }
124
+
125
+ /* ── Main panel ── */
126
+ function renderMain() {
127
+ const title = document.getElementById('main-title')
128
+ const tag = document.getElementById('main-tag')
129
+ const emptyState = document.getElementById('empty-state')
130
+ const treeView = document.getElementById('tree-view')
131
+ const rawView = document.getElementById('raw-view')
132
+ const diffView = document.getElementById('diff-view')
133
+
134
+ const hasStores = Object.keys(stateTree).length > 0
135
+
136
+ if (!selectedStore || !stateTree[selectedStore]) {
137
+ emptyState.style.display = hasStores ? '' : ''
138
+ treeView.style.display = 'none'
139
+ rawView.style.display = 'none'
140
+ diffView.style.display = 'none'
141
+ emptyState.style.display = ''
142
+ title.textContent = '—'
143
+ tag.textContent = 'no store selected'
144
+ return
145
+ }
146
+
147
+ const data = selectedHistoryId != null
148
+ ? (historyLog.find(h => h.id === selectedHistoryId)?.state?.[selectedStore] ?? stateTree[selectedStore])
149
+ : stateTree[selectedStore]
150
+
151
+ title.textContent = selectedStore
152
+ tag.textContent = selectedHistoryId != null ? `time travel #${selectedHistoryId}` : 'live'
153
+ tag.style.color = selectedHistoryId != null ? 'var(--accent3)' : 'var(--text-dim)'
154
+
155
+ emptyState.style.display = 'none'
156
+ treeView.style.display = activeTab === 'tree' ? '' : 'none'
157
+ rawView.style.display = activeTab === 'raw' ? '' : 'none'
158
+ diffView.style.display = activeTab === 'diff' ? '' : 'none'
159
+
160
+ if (activeTab === 'tree') {
161
+ treeView.innerHTML = ''
162
+ treeView.appendChild(renderJsonTree(data, selectedStore))
163
+ } else if (activeTab === 'raw') {
164
+ document.getElementById('raw-content').textContent = JSON.stringify(data, null, 2)
165
+ } else if (activeTab === 'diff') {
166
+ renderDiffView(diffView, lastDiff[selectedStore] || {})
167
+ }
168
+ }
169
+
170
+ /* ── JSON Tree ── */
171
+ function renderJsonTree(value, path) {
172
+ const wrap = document.createElement('div')
173
+ wrap.className = 'json-tree'
174
+ buildNode(wrap, value, null, path, 0)
175
+ return wrap
176
+ }
177
+
178
+ function buildNode(parent, value, key, path, depth) {
179
+ const type = typeof value
180
+ const row = document.createElement('div')
181
+ row.className = 'json-row'
182
+
183
+ const isObj = value !== null && type === 'object'
184
+ const isArr = Array.isArray(value)
185
+ const childCount = isObj ? Object.keys(value).length : 0
186
+
187
+ if (isObj && childCount > 0) {
188
+ const collapsed = collapsedPaths.has(path)
189
+ const toggle = document.createElement('span')
190
+ toggle.className = 'json-collapse'
191
+ toggle.textContent = collapsed ? '▶' : '▼'
192
+ toggle.onclick = () => {
193
+ if (collapsedPaths.has(path)) collapsedPaths.delete(path)
194
+ else collapsedPaths.add(path)
195
+ renderMain()
196
+ }
197
+ row.appendChild(toggle)
198
+ if (key !== null) {
199
+ const k = document.createElement('span')
200
+ k.className = 'json-key'
201
+ k.textContent = JSON.stringify(key)
202
+ row.appendChild(k)
203
+ row.appendChild(makeSpan('json-colon', ': '))
204
+ }
205
+ row.appendChild(makeSpan('', isArr ? `[${childCount}]` : `{${childCount}}`))
206
+ row.style.color = 'var(--text-dim)'
207
+ parent.appendChild(row)
208
+
209
+ if (!collapsed) {
210
+ const kids = document.createElement('div')
211
+ kids.className = 'json-children'
212
+ for (const [k2, v2] of Object.entries(value)) {
213
+ buildNode(kids, v2, k2, `${path}.${k2}`, depth + 1)
214
+ }
215
+ parent.appendChild(kids)
216
+ }
217
+ } else {
218
+ const spacer = document.createElement('span')
219
+ spacer.style.display = 'inline-block'
220
+ spacer.style.width = '14px'
221
+ row.appendChild(spacer)
222
+
223
+ if (key !== null) {
224
+ row.appendChild(makeSpan('json-key', JSON.stringify(key)))
225
+ row.appendChild(makeSpan('json-colon', ': '))
226
+ }
227
+
228
+ if (value === null) {
229
+ row.appendChild(makeSpan('json-null', 'null'))
230
+ } else if (type === 'string') {
231
+ row.appendChild(makeSpan('json-string', JSON.stringify(value)))
232
+ } else if (type === 'number') {
233
+ row.appendChild(makeSpan('json-number', String(value)))
234
+ } else if (type === 'boolean') {
235
+ row.appendChild(makeSpan('json-bool', String(value)))
236
+ } else {
237
+ row.appendChild(makeSpan('', '{}'))
238
+ }
239
+ parent.appendChild(row)
240
+ }
241
+ }
242
+
243
+ function makeSpan(cls, text) {
244
+ const s = document.createElement('span')
245
+ if (cls) s.className = cls
246
+ s.textContent = text
247
+ return s
248
+ }
249
+
250
+ /* ── Diff view ── */
251
+ function renderDiffView(container, diff) {
252
+ container.innerHTML = ''
253
+ if (!diff || Object.keys(diff).length === 0) {
254
+ const empty = document.createElement('div')
255
+ empty.className = 'empty-state'
256
+ empty.innerHTML = '<div class="big">≈</div><div class="hint">No diff recorded yet.<br>Change a store value to see what changed.</div>'
257
+ container.appendChild(empty)
258
+ return
259
+ }
260
+
261
+ for (const [key, change] of Object.entries(diff)) {
262
+ const line = document.createElement('div')
263
+ line.className = 'diff-line changed'
264
+ line.innerHTML = `
265
+ <span class="diff-key">${key}</span>
266
+ <span class="diff-arrow">:</span>
267
+ <span class="diff-old">${JSON.stringify(change.from)}</span>
268
+ <span class="diff-arrow">→</span>
269
+ <span class="diff-new">${JSON.stringify(change.to)}</span>
270
+ `
271
+ container.appendChild(line)
272
+ }
273
+ }
274
+
275
+ /* ── History panel ── */
276
+ function renderHistory() {
277
+ const list = document.getElementById('history-list')
278
+ const count = document.getElementById('history-count')
279
+ count.textContent = historyLog.length
280
+
281
+ if (historyLog.length === 0) {
282
+ list.innerHTML = '<div style="padding:12px 14px;color:var(--text-dim);font-size:11px">No history yet</div>'
283
+ return
284
+ }
285
+
286
+ list.innerHTML = [...historyLog].reverse().map(entry => {
287
+ const active = entry.id === selectedHistoryId ? ' active' : ''
288
+ const time = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 2 })
289
+ const pills = entry.diff ? Object.entries(entry.diff).slice(0, 4).map(([k]) =>
290
+ `<span class="hist-pill change">${k}</span>`
291
+ ).join('') : ''
292
+ return `
293
+ <div class="hist-item${active}" onclick="selectHistory(${entry.id})">
294
+ <span class="hist-store">${entry.store}</span>
295
+ <span class="hist-time">${time}</span>
296
+ ${pills ? `<div class="hist-diff-pills">${pills}</div>` : ''}
297
+ </div>`
298
+ }).join('')
299
+ }
300
+
301
+ function selectHistory(id) {
302
+ if (selectedHistoryId === id) {
303
+ selectedHistoryId = null
304
+ } else {
305
+ selectedHistoryId = id
306
+ const entry = historyLog.find(h => h.id === id)
307
+ if (entry) selectedStore = entry.store
308
+ }
309
+ renderAll()
310
+ }
311
+
312
+ /* ── Tabs ── */
313
+ document.querySelectorAll('.tab').forEach(tab => {
314
+ tab.addEventListener('click', () => {
315
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'))
316
+ tab.classList.add('active')
317
+ activeTab = tab.dataset.tab
318
+ renderMain()
319
+ })
320
+ })
321
+
322
+ /* ── Clear ── */
323
+ document.getElementById('btn-clear').addEventListener('click', () => {
324
+ fetch('/state', { method: 'DELETE' }).catch(() => {})
325
+ })
326
+
327
+ /* ── Boot ── */
328
+ connect()
329
+ renderAll()
@@ -0,0 +1,203 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Valtio Inspector</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+
12
+ :root {
13
+ --bg: #0d0f14;
14
+ --bg2: #12151c;
15
+ --bg3: #191d27;
16
+ --bg4: #1f2432;
17
+ --border: #262c3d;
18
+ --border2: #2e3549;
19
+ --text: #c8d0e8;
20
+ --text-dim: #5a6480;
21
+ --text-mid: #8892b0;
22
+ --accent: #7c6af7;
23
+ --accent2: #56e0a0;
24
+ --accent3: #f7a26a;
25
+ --accent4: #f06c8a;
26
+ --add: #56e0a0;
27
+ --remove: #f06c8a;
28
+ --change: #f7a26a;
29
+ --dot-size: 6px;
30
+ }
31
+
32
+ html, body { height: 100%; background: var(--bg); color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 13px; overflow: hidden; }
33
+
34
+ /* ── Layout ── */
35
+ #app { display: grid; grid-template-columns: 220px 1fr 260px; grid-template-rows: 48px 1fr; height: 100vh; }
36
+
37
+ /* ── Topbar ── */
38
+ #topbar {
39
+ grid-column: 1 / -1;
40
+ display: flex; align-items: center; gap: 16px;
41
+ padding: 0 20px;
42
+ background: var(--bg2);
43
+ border-bottom: 1px solid var(--border);
44
+ position: relative;
45
+ z-index: 10;
46
+ }
47
+ #topbar .logo { display: flex; align-items: center; gap: 8px; font-family: 'Space Mono', monospace; font-size: 14px; font-weight: 700; letter-spacing: -0.02em; }
48
+ #topbar .logo .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); animation: pulse 2s ease-in-out infinite; }
49
+ @keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.6;transform:scale(.85)} }
50
+ #topbar .spacer { flex: 1; }
51
+ .status-pill { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); padding: 4px 10px; border-radius: 20px; border: 1px solid var(--border); }
52
+ .status-pill .led { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); transition: background .3s, box-shadow .3s; }
53
+ .status-pill.connected .led { background: var(--accent2); box-shadow: 0 0 8px var(--accent2); }
54
+ .status-pill.connected { border-color: #2a4040; color: var(--accent2); }
55
+ #btn-clear { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text-dim); cursor: pointer; padding: 4px 12px; font-family: inherit; font-size: 11px; transition: all .2s; }
56
+ #btn-clear:hover { border-color: var(--accent4); color: var(--accent4); }
57
+
58
+ /* ── Sidebar ── */
59
+ #sidebar {
60
+ background: var(--bg2);
61
+ border-right: 1px solid var(--border);
62
+ overflow-y: auto;
63
+ display: flex; flex-direction: column;
64
+ }
65
+ #sidebar-head { padding: 12px 14px 8px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .12em; color: var(--text-dim); }
66
+ .store-item {
67
+ display: flex; align-items: center; gap: 10px;
68
+ padding: 8px 14px; cursor: pointer;
69
+ border-left: 2px solid transparent;
70
+ transition: background .15s, border-color .15s;
71
+ position: relative;
72
+ }
73
+ .store-item:hover { background: var(--bg3); }
74
+ .store-item.active { background: var(--bg3); border-left-color: var(--accent); }
75
+ .store-item .store-icon { width: 22px; height: 22px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 11px; background: var(--bg4); color: var(--accent); flex-shrink: 0; }
76
+ .store-item .store-name { flex: 1; color: var(--text); font-size: 12px; }
77
+ .store-item .store-flash { position: absolute; inset: 0; background: var(--accent); opacity: 0; pointer-events: none; border-radius: 0; }
78
+ .store-item.flash .store-flash { animation: flash-anim .4s ease-out; }
79
+ @keyframes flash-anim { 0%{opacity:.12} 100%{opacity:0} }
80
+
81
+ /* ── Main ── */
82
+ #main { display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
83
+ #main-head { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0; }
84
+ #main-head .store-label { font-size: 14px; font-weight: 600; color: var(--text); }
85
+ #main-head .store-tag { font-size: 10px; color: var(--text-dim); background: var(--bg4); border: 1px solid var(--border); padding: 2px 8px; border-radius: 4px; }
86
+ #tabs { display: flex; gap: 2px; padding: 8px 16px 0; background: var(--bg2); border-bottom: 1px solid var(--border); }
87
+ .tab { padding: 6px 14px 7px; cursor: pointer; font-size: 11px; color: var(--text-dim); border-bottom: 2px solid transparent; transition: all .2s; border-radius: 4px 4px 0 0; }
88
+ .tab:hover { color: var(--text); background: var(--bg3); }
89
+ .tab.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--bg3); }
90
+ #view { flex: 1; overflow-y: auto; padding: 16px; }
91
+
92
+ /* ── JSON tree ── */
93
+ .json-tree { line-height: 1.7; }
94
+ .json-node { display: flex; flex-direction: column; }
95
+ .json-row { display: flex; align-items: baseline; gap: 6px; cursor: default; padding: 1px 4px; border-radius: 3px; }
96
+ .json-row:hover { background: var(--bg3); }
97
+ .json-key { color: #7ec8e3; }
98
+ .json-colon { color: var(--text-dim); }
99
+ .json-string { color: var(--accent2); }
100
+ .json-number { color: #f9c46b; }
101
+ .json-bool { color: var(--accent3); }
102
+ .json-null { color: var(--text-dim); }
103
+ .json-collapse { color: var(--text-dim); font-size: 10px; cursor: pointer; user-select: none; padding: 0 3px; }
104
+ .json-collapse:hover { color: var(--text); }
105
+ .json-children { padding-left: 18px; border-left: 1px solid var(--border); margin-left: 8px; }
106
+
107
+ /* ── History panel ── */
108
+ #history-panel { background: var(--bg2); border-left: 1px solid var(--border); overflow-y: auto; display: flex; flex-direction: column; }
109
+ #history-head { padding: 12px 14px 8px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .12em; color: var(--text-dim); border-bottom: 1px solid var(--border); flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; }
110
+ #history-count { font-size: 10px; color: var(--accent); background: #1e1a3a; border: 1px solid #2d2660; padding: 1px 7px; border-radius: 10px; }
111
+ #history-list { flex: 1; overflow-y: auto; }
112
+ .hist-item {
113
+ padding: 8px 14px; cursor: pointer;
114
+ border-bottom: 1px solid var(--border);
115
+ transition: background .15s;
116
+ display: flex; flex-direction: column; gap: 3px;
117
+ }
118
+ .hist-item:hover { background: var(--bg3); }
119
+ .hist-item.active { background: #191630; border-left: 2px solid var(--accent); }
120
+ .hist-item .hist-store { font-size: 11px; color: var(--accent); font-weight: 500; }
121
+ .hist-item .hist-time { font-size: 10px; color: var(--text-dim); }
122
+ .hist-item .hist-diff-pills { display: flex; flex-wrap: wrap; gap: 3px; }
123
+ .hist-pill { font-size: 9px; padding: 1px 6px; border-radius: 10px; font-weight: 500; }
124
+ .hist-pill.change { background: #2c200d; color: var(--change); border: 1px solid #3d2c12; }
125
+ .hist-pill.add { background: #0d2316; color: var(--add); border: 1px solid #123020; }
126
+ .hist-pill.remove { background: #2c0d14; color: var(--remove); border: 1px solid #3d1220; }
127
+
128
+ /* ── Empty / no store states ── */
129
+ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 10px; color: var(--text-dim); text-align: center; }
130
+ .empty-state .big { font-size: 32px; opacity: .3; }
131
+ .empty-state .hint { font-size: 11px; line-height: 1.8; max-width: 280px; }
132
+ .empty-state code { background: var(--bg3); padding: 2px 6px; border-radius: 4px; font-size: 11px; color: var(--accent); }
133
+
134
+ /* ── Diff view ── */
135
+ .diff-line { display: flex; gap: 8px; padding: 3px 8px; border-radius: 3px; font-size: 12px; align-items: baseline; }
136
+ .diff-line.changed { background: #1e1505; border-left: 2px solid var(--change); }
137
+ .diff-line.added { background: #061510; border-left: 2px solid var(--add); }
138
+ .diff-line.removed { background: #150509; border-left: 2px solid var(--remove); }
139
+ .diff-label { font-size: 10px; min-width: 50px; opacity: .7; }
140
+ .diff-key { color: #7ec8e3; }
141
+ .diff-arrow { color: var(--text-dim); }
142
+ .diff-val { color: var(--text); }
143
+ .diff-old { color: var(--remove); text-decoration: line-through; opacity: .7; }
144
+ .diff-new { color: var(--add); }
145
+
146
+ /* scrollbar */
147
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
148
+ ::-webkit-scrollbar-track { background: transparent; }
149
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
150
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
151
+ </style>
152
+ </head>
153
+ <body>
154
+ <div id="app">
155
+ <!-- Topbar -->
156
+ <div id="topbar">
157
+ <div class="logo"><span class="dot"></span> valtio inspector</div>
158
+ <div class="spacer"></div>
159
+ <div class="status-pill" id="status-pill"><span class="led"></span><span id="status-text">disconnected</span></div>
160
+ <button id="btn-clear">⌫ clear</button>
161
+ </div>
162
+
163
+ <!-- Sidebar: stores -->
164
+ <div id="sidebar">
165
+ <div id="sidebar-head">Stores</div>
166
+ <div id="store-list"></div>
167
+ </div>
168
+
169
+ <!-- Main panel -->
170
+ <div id="main">
171
+ <div id="main-head">
172
+ <span class="store-label" id="main-title">—</span>
173
+ <span class="store-tag" id="main-tag">no store selected</span>
174
+ </div>
175
+ <div id="tabs">
176
+ <div class="tab active" data-tab="tree">Tree</div>
177
+ <div class="tab" data-tab="raw">Raw JSON</div>
178
+ <div class="tab" data-tab="diff">Last Diff</div>
179
+ </div>
180
+ <div id="view">
181
+ <div class="empty-state" id="empty-state">
182
+ <div class="big">◎</div>
183
+ <div class="hint">No stores connected yet.<br>In your app, call <code>attachInspector(store, { name: 'myStore' })</code> and make sure the inspector server is running.</div>
184
+ </div>
185
+ <div id="tree-view" style="display:none"></div>
186
+ <div id="raw-view" style="display:none"><pre id="raw-content" style="line-height:1.7;color:var(--text-mid)"></pre></div>
187
+ <div id="diff-view" style="display:none"></div>
188
+ </div>
189
+ </div>
190
+
191
+ <!-- History panel -->
192
+ <div id="history-panel">
193
+ <div id="history-head">
194
+ <span>History</span>
195
+ <span id="history-count">0</span>
196
+ </div>
197
+ <div id="history-list"></div>
198
+ </div>
199
+ </div>
200
+
201
+ <script src="app.js"></script>
202
+ </body>
203
+ </html>
@@ -0,0 +1,19 @@
1
+ type AttachOptions = {
2
+ /** Display name for this store in the inspector */
3
+ name: string;
4
+ /** Debounce delay in ms (default: 100) */
5
+ debounce?: number;
6
+ /** Custom inspector URL (default: http://localhost:7777) */
7
+ inspectorUrl?: string;
8
+ };
9
+ /**
10
+ * Attach a Valtio proxy store to the inspector.
11
+ *
12
+ * Usage:
13
+ * attachInspector(myStore, { name: 'auth' })
14
+ *
15
+ * ⚠️ Wrap in process.env.NODE_ENV !== 'production' to exclude from builds.
16
+ */
17
+ export declare function attachInspector(state: object, options: AttachOptions): () => void;
18
+ export {};
19
+ //# sourceMappingURL=attachInspector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachInspector.d.ts","sourceRoot":"","sources":["../src/attachInspector.ts"],"names":[],"mappings":"AAEA,KAAK,aAAa,GAAG;IACnB,mDAAmD;IACnD,IAAI,EAAE,MAAM,CAAA;IACZ,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,aAAa,GACrB,MAAM,IAAI,CAmFZ"}
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.attachInspector = attachInspector;
4
+ const valtio_1 = require("valtio");
5
+ /**
6
+ * Attach a Valtio proxy store to the inspector.
7
+ *
8
+ * Usage:
9
+ * attachInspector(myStore, { name: 'auth' })
10
+ *
11
+ * ⚠️ Wrap in process.env.NODE_ENV !== 'production' to exclude from builds.
12
+ */
13
+ function attachInspector(state, options) {
14
+ if (typeof window === 'undefined' && typeof process !== 'undefined') {
15
+ // Node.js / SSR — skip silently
16
+ return () => { };
17
+ }
18
+ const { name, debounce = 100, inspectorUrl = 'http://localhost:7777' } = options;
19
+ const wsUrl = inspectorUrl.replace('http', 'ws');
20
+ let timeout = null;
21
+ let ws = null;
22
+ let reconnectAttempts = 0;
23
+ const queue = [];
24
+ function connect() {
25
+ ws = new WebSocket(wsUrl);
26
+ ws.onopen = () => {
27
+ reconnectAttempts = 0;
28
+ // flush queue
29
+ while (queue.length) {
30
+ ws.send(JSON.stringify(queue.shift()));
31
+ }
32
+ };
33
+ ws.onclose = () => {
34
+ const delay = Math.min(1000 * 2 ** reconnectAttempts, 10000);
35
+ reconnectAttempts++;
36
+ setTimeout(connect, delay);
37
+ };
38
+ ws.onerror = () => {
39
+ ws?.close();
40
+ };
41
+ }
42
+ function send() {
43
+ const payload = {
44
+ store: name,
45
+ data: (0, valtio_1.snapshot)(state)
46
+ };
47
+ // try WS first
48
+ if (ws && ws.readyState === WebSocket.OPEN) {
49
+ ws.send(JSON.stringify(payload));
50
+ }
51
+ else {
52
+ // queue for later
53
+ queue.push(payload);
54
+ // fallback to HTTP (optional)
55
+ fetch(`${inspectorUrl}/state`, {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(payload)
59
+ }).catch(() => {
60
+ // Inspector not running — fail silently
61
+ });
62
+ }
63
+ }
64
+ // init connection
65
+ connect();
66
+ // Send initial snapshot immediately
67
+ send();
68
+ const unsubscribe = (0, valtio_1.subscribe)(state, () => {
69
+ if (timeout)
70
+ clearTimeout(timeout);
71
+ timeout = setTimeout(send, debounce);
72
+ });
73
+ return () => {
74
+ if (timeout)
75
+ clearTimeout(timeout);
76
+ unsubscribe();
77
+ ws?.close();
78
+ };
79
+ }
80
+ //# sourceMappingURL=attachInspector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachInspector.js","sourceRoot":"","sources":["../src/attachInspector.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const server_1 = require("./server");
5
+ (0, server_1.startServer)();
6
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";;;AAEA,qCAAuC;AAEvC,IAAA,oBAAW,GAAE,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { attachInspector } from './attachInspector';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.attachInspector = void 0;
4
+ var attachInspector_1 = require("./attachInspector");
5
+ Object.defineProperty(exports, "attachInspector", { enumerable: true, get: function () { return attachInspector_1.attachInspector; } });
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,qDAAmD;AAA1C,kHAAA,eAAe,OAAA"}
@@ -0,0 +1,2 @@
1
+ export declare function startServer(port?: number): void;
2
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAMA,wBAAgB,WAAW,CAAC,IAAI,GAAE,MAAa,QAwH9C"}
package/dist/server.js ADDED
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startServer = startServer;
7
+ const express_1 = __importDefault(require("express"));
8
+ const ws_1 = require("ws");
9
+ const http_1 = __importDefault(require("http"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const cors_1 = __importDefault(require("cors"));
12
+ function startServer(port = 7777) {
13
+ const app = (0, express_1.default)();
14
+ app.use((0, cors_1.default)({
15
+ origin: 'http://localhost:5173'
16
+ }));
17
+ app.use(express_1.default.json());
18
+ const server = http_1.default.createServer(app);
19
+ const wss = new ws_1.WebSocketServer({ server });
20
+ let stateTree = {};
21
+ let history = [];
22
+ let historyIdCounter = 0;
23
+ const MAX_HISTORY = 100;
24
+ function computeDiff(prev, next) {
25
+ if (!prev)
26
+ return null;
27
+ const diff = {};
28
+ const allKeys = new Set([...Object.keys(prev || {}), ...Object.keys(next || {})]);
29
+ for (const key of allKeys) {
30
+ if (JSON.stringify(prev[key]) !== JSON.stringify(next[key])) {
31
+ diff[key] = { from: prev[key], to: next[key] };
32
+ }
33
+ }
34
+ return Object.keys(diff).length > 0 ? diff : null;
35
+ }
36
+ function broadcast(payload) {
37
+ const msg = JSON.stringify(payload);
38
+ wss.clients.forEach(client => {
39
+ if (client.readyState === ws_1.WebSocket.OPEN) {
40
+ client.send(msg);
41
+ }
42
+ });
43
+ }
44
+ app.post('/state', (req, res) => {
45
+ const { store, data } = req.body;
46
+ if (!store || data === undefined) {
47
+ res.sendStatus(400);
48
+ return;
49
+ }
50
+ const prev = stateTree[store];
51
+ stateTree[store] = data;
52
+ const diff = computeDiff(prev, data);
53
+ const entry = {
54
+ id: ++historyIdCounter,
55
+ timestamp: Date.now(),
56
+ store,
57
+ state: JSON.parse(JSON.stringify(stateTree)),
58
+ diff: diff ?? undefined
59
+ };
60
+ history.push(entry);
61
+ if (history.length > MAX_HISTORY) {
62
+ history.shift();
63
+ }
64
+ broadcast({
65
+ type: 'STATE_UPDATE',
66
+ state: stateTree,
67
+ entry: { id: entry.id, timestamp: entry.timestamp, store, diff }
68
+ });
69
+ res.sendStatus(200);
70
+ });
71
+ app.get('/history', (_req, res) => res.json(history));
72
+ app.get('/history/:id', (req, res) => {
73
+ const id = parseInt(req.params.id);
74
+ const entry = history.find(h => h.id === id);
75
+ if (!entry) {
76
+ res.sendStatus(404);
77
+ return;
78
+ }
79
+ res.json(entry);
80
+ });
81
+ app.get('/state', (_req, res) => res.json(stateTree));
82
+ app.delete('/state', (_req, res) => {
83
+ stateTree = {};
84
+ history = [];
85
+ broadcast({ type: 'CLEAR' });
86
+ res.sendStatus(200);
87
+ });
88
+ app.use(express_1.default.static(path_1.default.join(__dirname, '../client')));
89
+ wss.on('connection', (ws) => {
90
+ ws.send(JSON.stringify({
91
+ type: 'INIT',
92
+ state: stateTree,
93
+ history: history.map(h => ({
94
+ id: h.id,
95
+ timestamp: h.timestamp,
96
+ store: h.store,
97
+ diff: h.diff
98
+ }))
99
+ }));
100
+ });
101
+ server.listen(port, () => {
102
+ console.log('\x1b[36m%s\x1b[0m', ` ▶ Valtio Inspector → http://localhost:${port}`);
103
+ console.log('\x1b[90m%s\x1b[0m', ` WebSocket ready on ws://localhost:${port}`);
104
+ });
105
+ }
106
+ // startServer()
107
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/example.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Example: how to wire up valtio stores with the inspector
3
+ * Drop this into your app as a reference.
4
+ */
5
+
6
+ import { proxy } from 'valtio'
7
+ import { attachInspector } from './instrument/attachInspector'
8
+
9
+ // ── Define your stores ──────────────────────────────────────────────────────
10
+
11
+ export const authStore = proxy({
12
+ user: { id: 1, name: 'kfir', email: 'kfir@example.com' },
13
+ isLoggedIn: false,
14
+ token: null as string | null
15
+ })
16
+
17
+ export const uiStore = proxy({
18
+ darkMode: true,
19
+ sidebarOpen: false,
20
+ activeModal: null as string | null,
21
+ notifications: [] as string[]
22
+ })
23
+
24
+ export const cartStore = proxy({
25
+ items: [] as Array<{ id: string; name: string; qty: number; price: number }>,
26
+ coupon: null as string | null
27
+ })
28
+
29
+ // ── Attach inspector (dev only) ─────────────────────────────────────────────
30
+
31
+ if (process.env.NODE_ENV !== 'production') {
32
+ attachInspector(authStore, { name: 'auth' })
33
+ attachInspector(uiStore, { name: 'ui' })
34
+ attachInspector(cartStore, { name: 'cart' })
35
+ }
36
+
37
+ // ── Test: mutate stores after 1 second ─────────────────────────────────────
38
+ // (remove in real app)
39
+
40
+ setTimeout(() => {
41
+ authStore.isLoggedIn = true
42
+ authStore.token = 'jwt_abc123'
43
+ }, 1000)
44
+
45
+ setTimeout(() => {
46
+ uiStore.sidebarOpen = true
47
+ uiStore.activeModal = 'checkout'
48
+ }, 2000)
49
+
50
+ setTimeout(() => {
51
+ cartStore.items.push({ id: 'p1', name: 'Mechanical Keyboard', qty: 1, price: 149.99 })
52
+ cartStore.items.push({ id: 'p2', name: 'USB-C Hub', qty: 2, price: 34.99 })
53
+ }, 3000)
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@kfiross44/valtio-inspector",
3
+ "version": "0.9.0",
4
+ "description": "A dev-only Valtio state inspector with WebSocket live updates",
5
+ "main": "dist/server/index.js",
6
+ "bin": {
7
+ "valtio-inspector": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "ts-node server/index.ts",
11
+ "build": "tsc",
12
+ "release": "bun run build && changeset publish",
13
+ "start": "node dist/server/index.js"
14
+ },
15
+ "dependencies": {
16
+ "cors": "^2.8.6",
17
+ "express": "^4.18.2",
18
+ "ws": "^8.16.0"
19
+ },
20
+ "peerDependencies": {
21
+ "valtio": "^2.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@changesets/cli": "^2.30.0",
25
+ "@types/cors": "^2.8.19",
26
+ "@types/express": "^4.17.21",
27
+ "@types/node": "^20.19.39",
28
+ "@types/ws": "^8.5.10",
29
+ "ts-node": "^10.9.2",
30
+ "typescript": "^5.3.3"
31
+ }
32
+ }
@@ -0,0 +1,14 @@
1
+ import { Glob, $ } from 'bun'
2
+
3
+ await $`rm -rf dist`
4
+ const files = new Glob('./src/**/*.{ts,tsx}').scan()
5
+ for await (const file of files) {
6
+ await Bun.build({
7
+ format: 'esm',
8
+ outdir: 'dist/esm',
9
+ external: ['*'],
10
+ root: 'src',
11
+ entrypoints: [file],
12
+ })
13
+ }
14
+ await $`tsc --outDir dist/types --declaration --emitDeclarationOnly --declarationMap`
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Get the package version from package.json
4
+ PACKAGE_VERSION=$(node -p -e "require('./package.json').version || ''")
5
+
6
+ # Check if PACKAGE_VERSION is empty
7
+ if [ -z "$PACKAGE_VERSION" ]; then
8
+ echo "Error: Failed to retrieve version from package.json"
9
+ exit 1
10
+ fi
11
+
12
+ # Run git-cliff with the correct tag
13
+ bun x git-cliff -o './CHANGELOG.md' --tag "$PACKAGE_VERSION"
@@ -0,0 +1,106 @@
1
+ import { subscribe, snapshot } from 'valtio'
2
+
3
+ type AttachOptions = {
4
+ /** Display name for this store in the inspector */
5
+ name: string
6
+ /** Debounce delay in ms (default: 100) */
7
+ debounce?: number
8
+ /** Custom inspector URL (default: http://localhost:7777) */
9
+ inspectorUrl?: string
10
+ }
11
+
12
+ /**
13
+ * Attach a Valtio proxy store to the inspector.
14
+ *
15
+ * Usage:
16
+ * attachInspector(myStore, { name: 'auth' })
17
+ *
18
+ * ⚠️ Wrap in process.env.NODE_ENV !== 'production' to exclude from builds.
19
+ */
20
+ export function attachInspector(
21
+ state: object,
22
+ options: AttachOptions
23
+ ): () => void {
24
+ if (typeof window === 'undefined' && typeof process !== 'undefined') {
25
+ // Node.js / SSR — skip silently
26
+ return () => {}
27
+ }
28
+
29
+ const {
30
+ name,
31
+ debounce = 100,
32
+ inspectorUrl = 'http://localhost:7777'
33
+ } = options
34
+
35
+ const wsUrl = inspectorUrl.replace('http', 'ws')
36
+
37
+ let timeout: ReturnType<typeof setTimeout> | null = null
38
+ let ws: WebSocket | null = null
39
+ let reconnectAttempts = 0
40
+ const queue: any[] = []
41
+
42
+ function connect() {
43
+ ws = new WebSocket(wsUrl)
44
+
45
+ ws.onopen = () => {
46
+ reconnectAttempts = 0
47
+
48
+ // flush queue
49
+ while (queue.length) {
50
+ ws!.send(JSON.stringify(queue.shift()))
51
+ }
52
+ }
53
+
54
+ ws.onclose = () => {
55
+ const delay = Math.min(1000 * 2 ** reconnectAttempts, 10000)
56
+ reconnectAttempts++
57
+
58
+ setTimeout(connect, delay)
59
+ }
60
+
61
+ ws.onerror = () => {
62
+ ws?.close()
63
+ }
64
+ }
65
+
66
+ function send() {
67
+ const payload = {
68
+ store: name,
69
+ data: snapshot(state)
70
+ }
71
+
72
+ // try WS first
73
+ if (ws && ws.readyState === WebSocket.OPEN) {
74
+ ws.send(JSON.stringify(payload))
75
+ } else {
76
+ // queue for later
77
+ queue.push(payload)
78
+
79
+ // fallback to HTTP (optional)
80
+ fetch(`${inspectorUrl}/state`, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify(payload)
84
+ }).catch(() => {
85
+ // Inspector not running — fail silently
86
+ })
87
+ }
88
+ }
89
+
90
+ // init connection
91
+ connect()
92
+
93
+ // Send initial snapshot immediately
94
+ send()
95
+
96
+ const unsubscribe = subscribe(state, () => {
97
+ if (timeout) clearTimeout(timeout)
98
+ timeout = setTimeout(send, debounce)
99
+ })
100
+
101
+ return () => {
102
+ if (timeout) clearTimeout(timeout)
103
+ unsubscribe()
104
+ ws?.close()
105
+ }
106
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { startServer } from './server';
4
+
5
+ startServer();
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { attachInspector } from './attachInspector'
package/src/server.ts ADDED
@@ -0,0 +1,129 @@
1
+ import express from 'express'
2
+ import { WebSocketServer, WebSocket } from 'ws'
3
+ import http from 'http'
4
+ import path from 'path'
5
+ import cors from 'cors'
6
+
7
+ export function startServer(port: number = 7777) {
8
+ const app = express()
9
+
10
+ app.use(cors({
11
+ origin: 'http://localhost:5173'
12
+ }))
13
+
14
+ app.use(express.json())
15
+
16
+ const server = http.createServer(app)
17
+ const wss = new WebSocketServer({ server })
18
+
19
+ interface HistoryEntry {
20
+ id: number
21
+ timestamp: number
22
+ store: string
23
+ state: Record<string, any>
24
+ diff?: Record<string, any>
25
+ }
26
+
27
+ let stateTree: Record<string, any> = {}
28
+ let history: HistoryEntry[] = []
29
+ let historyIdCounter = 0
30
+ const MAX_HISTORY = 100
31
+
32
+ function computeDiff(prev: any, next: any): Record<string, any> | null {
33
+ if (!prev) return null
34
+ const diff: Record<string, any> = {}
35
+ const allKeys = new Set([...Object.keys(prev || {}), ...Object.keys(next || {})])
36
+ for (const key of allKeys) {
37
+ if (JSON.stringify(prev[key]) !== JSON.stringify(next[key])) {
38
+ diff[key] = { from: prev[key], to: next[key] }
39
+ }
40
+ }
41
+ return Object.keys(diff).length > 0 ? diff : null
42
+ }
43
+
44
+ function broadcast(payload: object) {
45
+ const msg = JSON.stringify(payload)
46
+ wss.clients.forEach(client => {
47
+ if (client.readyState === WebSocket.OPEN) {
48
+ client.send(msg)
49
+ }
50
+ })
51
+ }
52
+
53
+ app.post('/state', (req, res) => {
54
+ const { store, data } = req.body
55
+ if (!store || data === undefined) {
56
+ res.sendStatus(400)
57
+ return
58
+ }
59
+
60
+ const prev = stateTree[store]
61
+ stateTree[store] = data
62
+
63
+ const diff = computeDiff(prev, data)
64
+
65
+ const entry: HistoryEntry = {
66
+ id: ++historyIdCounter,
67
+ timestamp: Date.now(),
68
+ store,
69
+ state: JSON.parse(JSON.stringify(stateTree)),
70
+ diff: diff ?? undefined
71
+ }
72
+
73
+ history.push(entry)
74
+ if (history.length > MAX_HISTORY) {
75
+ history.shift()
76
+ }
77
+
78
+ broadcast({
79
+ type: 'STATE_UPDATE',
80
+ state: stateTree,
81
+ entry: { id: entry.id, timestamp: entry.timestamp, store, diff }
82
+ })
83
+
84
+ res.sendStatus(200)
85
+ })
86
+
87
+ app.get('/history', (_req, res) => res.json(history))
88
+
89
+ app.get('/history/:id', (req, res) => {
90
+ const id = parseInt(req.params.id)
91
+ const entry = history.find(h => h.id === id)
92
+ if (!entry) {
93
+ res.sendStatus(404)
94
+ return
95
+ }
96
+ res.json(entry)
97
+ })
98
+
99
+ app.get('/state', (_req, res) => res.json(stateTree))
100
+
101
+ app.delete('/state', (_req, res) => {
102
+ stateTree = {}
103
+ history = []
104
+ broadcast({ type: 'CLEAR' })
105
+ res.sendStatus(200)
106
+ })
107
+
108
+ app.use(express.static(path.join(__dirname, '../client')))
109
+
110
+ wss.on('connection', (ws) => {
111
+ ws.send(JSON.stringify({
112
+ type: 'INIT',
113
+ state: stateTree,
114
+ history: history.map(h => ({
115
+ id: h.id,
116
+ timestamp: h.timestamp,
117
+ store: h.store,
118
+ diff: h.diff
119
+ }))
120
+ }))
121
+ })
122
+
123
+ server.listen(port, () => {
124
+ console.log('\x1b[36m%s\x1b[0m', ` ▶ Valtio Inspector → http://localhost:${port}`)
125
+ console.log('\x1b[90m%s\x1b[0m', ` WebSocket ready on ws://localhost:${port}`)
126
+ })
127
+ }
128
+
129
+ // startServer()
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020", "DOM"],
6
+ "rootDir": "src",
7
+ "outDir": "dist",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["node_modules", "dist", "client"]
18
+ }