haltija 1.2.7 → 1.3.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -2
- package/apps/desktop/cdp-network.js +379 -0
- package/apps/desktop/main.js +224 -115
- package/apps/desktop/package.json +3 -4
- package/apps/desktop/preload.js +9 -1
- package/apps/desktop/renderer/agent-status.js +25 -1
- package/apps/desktop/renderer/tabs.js +123 -10
- package/apps/desktop/renderer/widget-status.js +224 -0
- package/apps/desktop/renderer.js +58 -3
- package/apps/desktop/resources/component.js +253 -131
- package/apps/desktop/styles.css +101 -6
- package/apps/desktop/terminal.html +811 -43
- package/apps/desktop/webview-preload.js +6 -0
- package/bin/cli-subcommand.mjs +156 -137
- package/bin/format-network.mjs +90 -0
- package/bin/hj.mjs +93 -14
- package/bin/tosijs-dev.mjs +27 -5
- package/dist/agent-message-format.d.ts +56 -0
- package/dist/agent-shell.d.ts +161 -0
- package/dist/api-handlers.d.ts +107 -0
- package/dist/api-router.d.ts +29 -0
- package/dist/api-schema.d.ts +894 -0
- package/dist/bookmarklet.d.ts +21 -0
- package/dist/browser-tests.d.ts +49 -0
- package/dist/client.d.ts +168 -0
- package/dist/codemirror-bundle.d.ts +16 -0
- package/dist/codemirror-raw.js +29 -0
- package/dist/codemirror.js +30 -0
- package/dist/component.d.ts +391 -0
- package/dist/component.esm.js +7456 -0
- package/dist/component.js +253 -131
- package/dist/embedded-assets.d.ts +18 -0
- package/dist/hj.js +302 -151
- package/dist/index.d.ts +46 -0
- package/dist/index.js +8229 -311
- package/dist/key-codes.d.ts +12 -0
- package/dist/mcp-config.d.ts +71 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +8229 -311
- package/dist/sessions.d.ts +49 -0
- package/dist/task-board.d.ts +45 -0
- package/dist/tasks.d.ts +77 -0
- package/dist/terminal.d.ts +154 -0
- package/dist/test-data.d.ts +37 -0
- package/dist/test-formatters.d.ts +68 -0
- package/dist/test-generator.d.ts +37 -0
- package/dist/test-page.d.ts +8 -0
- package/dist/test.d.ts +136 -0
- package/dist/text-selector.d.ts +30 -0
- package/dist/types.d.ts +1054 -0
- package/dist/version.d.ts +11 -0
- package/docs/AGENTIC-IDE.md +309 -0
- package/llms.txt +47 -0
- package/package.json +15 -2
package/README.md
CHANGED
|
@@ -141,9 +141,9 @@ Raw DOM events are noise. Haltija aggregates them into intent:
|
|
|
141
141
|
|
|
142
142
|
Screenshots include a chyron (title, URL, timestamp) so agents always know what they're looking at. Disable with `chyron: false` for clean captures.
|
|
143
143
|
|
|
144
|
-
### Multi-Window
|
|
144
|
+
### Multi-Window
|
|
145
145
|
|
|
146
|
-
Control multiple tabs.
|
|
146
|
+
Control multiple tabs. The focused tab receives untargeted commands; pass `?window=<id>` (REST) or `--window <id>` (CLI) to target a specific tab.
|
|
147
147
|
|
|
148
148
|
### Selection Tool
|
|
149
149
|
|
|
@@ -190,6 +190,59 @@ Your agent uses the same `hj` commands either way — it doesn't know or care wh
|
|
|
190
190
|
|
|
191
191
|
---
|
|
192
192
|
|
|
193
|
+
## Embed Haltija in Your Own App
|
|
194
|
+
|
|
195
|
+
Building a tool, dev environment, or product that wants an agent eye built in? Run a haltija server on a port you choose and import the widget directly. Two flavours:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
// Visible — widget renders its own UI in the corner
|
|
199
|
+
import { inject } from 'haltija/component'
|
|
200
|
+
inject('ws://localhost:9123/ws/browser')
|
|
201
|
+
|
|
202
|
+
// Headless — widget is present but invisible; agent still has full control
|
|
203
|
+
inject('ws://localhost:9123/ws/browser', { mode: 'headless' })
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Or via HTML, no JS required:
|
|
207
|
+
|
|
208
|
+
```html
|
|
209
|
+
<haltija-dev server="ws://localhost:9123/ws/browser"></haltija-dev>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Tell `hj` which server to talk to (per-shell):
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
# Named instance — recommended, no port juggling
|
|
216
|
+
haltija --name dashboard --server # in one shell: register as "dashboard"
|
|
217
|
+
export HALTIJA_NAME=dashboard # in your other shells
|
|
218
|
+
hj tree # finds dashboard via ~/.haltija/servers/
|
|
219
|
+
|
|
220
|
+
# Port-based — if you'd rather pin a number
|
|
221
|
+
haltija --port 9123 --server
|
|
222
|
+
export HALTIJA_PORT=9123
|
|
223
|
+
hj tree
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
If you don't pass `--port`, haltija tries 8700 first and falls back to a kernel-assigned ephemeral port — `--name` records whichever port it ends up on so `hj` can find it. A different shell can target a different project; there's no global state, just one named instance per haltija server.
|
|
227
|
+
|
|
228
|
+
**Production embedding.** When haltija is reachable beyond loopback, gate it with a shared-secret token:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
haltija --port 9123 --token $(openssl rand -hex 16) --server
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```js
|
|
235
|
+
inject('ws://your-host:9123/ws/browser', { token: 'same-secret' })
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
HALTIJA_TOKEN=same-secret hj tree
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The server rejects every REST/WebSocket request without a matching `X-Haltija-Token` (or `?token=` for WebSockets). This is a stub — provide your own TLS, key rotation, and per-agent identity if you need them.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
193
246
|
## Installation
|
|
194
247
|
|
|
195
248
|
```bash
|
|
@@ -200,6 +253,7 @@ npm install -g haltija # Install globally
|
|
|
200
253
|
# Server options
|
|
201
254
|
haltija --https # HTTPS mode
|
|
202
255
|
haltija --port 3000 # Custom port
|
|
256
|
+
haltija --token <secret> # Require X-Haltija-Token on every request
|
|
203
257
|
haltija --headless # For CI pipelines
|
|
204
258
|
haltija --setup-mcp # Configure Claude Desktop
|
|
205
259
|
```
|
|
@@ -241,6 +295,30 @@ hj --help # CLI subcommand reference
|
|
|
241
295
|
|
|
242
296
|
---
|
|
243
297
|
|
|
298
|
+
## 1.3.0-beta.8 — change of direction
|
|
299
|
+
|
|
300
|
+
Earlier 1.3 betas tried to support multiple agents on a single haltija server by issuing each widget a *session token* and routing requests by `X-Haltija-Session`. The model was load-bearing but leaky — six of the last fifteen commits before this release were firefighting session-isolation regressions.
|
|
301
|
+
|
|
302
|
+
beta.8 deletes the entire mechanism and replaces it with **process boundaries**: each project runs its own haltija server, and the agent talks to the right one by **port** or by **name**.
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
haltija --name dashboard --server # one project, registers itself in ~/.haltija/servers/
|
|
306
|
+
HALTIJA_NAME=dashboard hj tree # any shell can address it by name
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
What this means for you, depending on how you used 1.3.0-beta.7:
|
|
310
|
+
|
|
311
|
+
- **`bunx haltija` desktop app** — works the same; no migration needed. The outer "chrome" widget that lets the app inspect itself now lives on a separate internal port (8701) so it never appears in agent-facing window listings.
|
|
312
|
+
- **`HALTIJA_SESSION` / `--session` / `--secure` / the click-to-copy session badge** — gone. If you were setting `HALTIJA_SESSION` in your shell, replace it with `HALTIJA_NAME` (and start the server with `--name <foo>`) or `HALTIJA_PORT`.
|
|
313
|
+
- **`inject(url, { session })`** — the `session` option is removed. If you need auth, use `inject(url, { token })` (matches `haltija --token <secret>`); for embedding without auth, just `inject(url)` or `inject(url, { mode: 'headless' })`.
|
|
314
|
+
- **`hj-chrome` exclusion logic** — gone from the public REST API. To inspect the desktop app's outer UI from `hj`, target the internal port directly: `HALTIJA_PORT=8701 hj tree`.
|
|
315
|
+
|
|
316
|
+
Net code change: ~830 lines removed, ~150 added back for the simpler model. Test count went up (we now have integration coverage for the token stub, named instances, and auto-port fallback that the previous betas lacked).
|
|
317
|
+
|
|
318
|
+
The pre-revert state is preserved on the `multi-user-isolation` branch in case the multi-agent-on-one-server design ever needs revisiting.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
244
322
|
## License
|
|
245
323
|
|
|
246
324
|
Apache 2.0
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Network Monitor — Captures network traffic via Chrome DevTools Protocol.
|
|
3
|
+
*
|
|
4
|
+
* Uses Electron's webContents.debugger API to attach to webviews and
|
|
5
|
+
* capture request/response data. Output is token-optimized for AI agents.
|
|
6
|
+
*
|
|
7
|
+
* Usage from main process:
|
|
8
|
+
* const { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwork } = require('./cdp-network.js')
|
|
9
|
+
* attachNetwork(webContents) // Start monitoring
|
|
10
|
+
* getNetworkLog(webContents.id) // Get buffered entries
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Per-webContents state
|
|
14
|
+
const monitors = new Map()
|
|
15
|
+
|
|
16
|
+
// Default noise patterns — URLs matching these are filtered in standard/minimal presets
|
|
17
|
+
const NOISE_PATTERNS = [
|
|
18
|
+
/google-analytics\.com/,
|
|
19
|
+
/googletagmanager\.com/,
|
|
20
|
+
/facebook\.net\/tr/,
|
|
21
|
+
/doubleclick\.net/,
|
|
22
|
+
/hotjar\.com/,
|
|
23
|
+
/sentry\.io\/api/,
|
|
24
|
+
/clarity\.ms/,
|
|
25
|
+
/segment\.io/,
|
|
26
|
+
/mixpanel\.com/,
|
|
27
|
+
/amplitude\.com/,
|
|
28
|
+
/intercom\.io/,
|
|
29
|
+
/fullstory\.com/,
|
|
30
|
+
/newrelic\.com/,
|
|
31
|
+
/datadoghq\.com/,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
// Resource types considered "noise" for minimal preset
|
|
35
|
+
const ASSET_TYPES = new Set(['Image', 'Font', 'Stylesheet', 'Media'])
|
|
36
|
+
|
|
37
|
+
const PRESETS = {
|
|
38
|
+
errors: { showAssets: false, showNoise: false, errorsOnly: true },
|
|
39
|
+
minimal: { showAssets: false, showNoise: false, errorsOnly: false },
|
|
40
|
+
standard: { showAssets: true, showNoise: false, errorsOnly: false },
|
|
41
|
+
verbose: { showAssets: true, showNoise: true, errorsOnly: false },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Attach CDP Network monitoring to a webContents.
|
|
46
|
+
* @param {Electron.WebContents} wc
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {string} opts.preset - 'errors' | 'minimal' | 'standard' | 'verbose'
|
|
49
|
+
* @param {number} opts.maxBuffer - Max entries to keep (default 200)
|
|
50
|
+
* @param {string[]} opts.includePatterns - URL patterns to include (overrides noise filter)
|
|
51
|
+
* @param {string[]} opts.excludePatterns - Additional URL patterns to exclude
|
|
52
|
+
*/
|
|
53
|
+
function attachNetwork(wc, opts = {}) {
|
|
54
|
+
const wcId = wc.id
|
|
55
|
+
if (monitors.has(wcId)) {
|
|
56
|
+
// Already attached — update options
|
|
57
|
+
const mon = monitors.get(wcId)
|
|
58
|
+
mon.preset = opts.preset || mon.preset
|
|
59
|
+
mon.maxBuffer = opts.maxBuffer || mon.maxBuffer
|
|
60
|
+
return { success: true, alreadyAttached: true }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const monitor = {
|
|
64
|
+
wc,
|
|
65
|
+
preset: opts.preset || 'standard',
|
|
66
|
+
maxBuffer: opts.maxBuffer || 200,
|
|
67
|
+
includePatterns: (opts.includePatterns || []).map(p => new RegExp(p)),
|
|
68
|
+
excludePatterns: (opts.excludePatterns || []).map(p => new RegExp(p)),
|
|
69
|
+
entries: [], // circular buffer of NetworkEntry
|
|
70
|
+
pending: new Map(), // requestId → partial entry (waiting for response)
|
|
71
|
+
attached: false,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
wc.debugger.attach('1.3')
|
|
76
|
+
monitor.attached = true
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// Debugger may already be attached by DevTools
|
|
79
|
+
if (err.message?.includes('Already attached')) {
|
|
80
|
+
monitor.attached = true
|
|
81
|
+
} else {
|
|
82
|
+
return { success: false, error: `CDP attach failed: ${err.message}` }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Enable Network domain
|
|
87
|
+
wc.debugger.sendCommand('Network.enable', {}).catch(err => {
|
|
88
|
+
console.error(`[CDP Network] Network.enable failed for wc ${wcId}:`, err.message)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Listen for CDP events
|
|
92
|
+
const handler = (event, method, params) => {
|
|
93
|
+
handleCdpEvent(monitor, method, params)
|
|
94
|
+
}
|
|
95
|
+
wc.debugger.on('message', handler)
|
|
96
|
+
monitor._handler = handler
|
|
97
|
+
|
|
98
|
+
// Clean up when webContents is destroyed
|
|
99
|
+
const destroyHandler = () => {
|
|
100
|
+
detachNetwork(wc)
|
|
101
|
+
}
|
|
102
|
+
wc.once('destroyed', destroyHandler)
|
|
103
|
+
monitor._destroyHandler = destroyHandler
|
|
104
|
+
|
|
105
|
+
monitors.set(wcId, monitor)
|
|
106
|
+
return { success: true }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detach CDP Network monitoring from a webContents.
|
|
111
|
+
*/
|
|
112
|
+
function detachNetwork(wc) {
|
|
113
|
+
const wcId = wc.id
|
|
114
|
+
const monitor = monitors.get(wcId)
|
|
115
|
+
if (!monitor) return
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (monitor._handler) {
|
|
119
|
+
wc.debugger.removeListener('message', monitor._handler)
|
|
120
|
+
}
|
|
121
|
+
if (monitor.attached) {
|
|
122
|
+
wc.debugger.sendCommand('Network.disable', {}).catch(() => {})
|
|
123
|
+
wc.debugger.detach()
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// Already detached or destroyed
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
monitors.delete(wcId)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get buffered network entries for a webContents, filtered by preset.
|
|
134
|
+
* @param {number} wcId - webContents.id
|
|
135
|
+
* @param {object} opts
|
|
136
|
+
* @param {string} opts.preset - Override the monitor's preset
|
|
137
|
+
* @param {number} opts.since - Only entries after this timestamp
|
|
138
|
+
* @param {number} opts.limit - Max entries to return
|
|
139
|
+
* @returns {{ entries: NetworkEntry[], summary: string }}
|
|
140
|
+
*/
|
|
141
|
+
function getNetworkLog(wcId, opts = {}) {
|
|
142
|
+
const monitor = monitors.get(wcId)
|
|
143
|
+
if (!monitor) return { entries: [], summary: 'not watching' }
|
|
144
|
+
|
|
145
|
+
const preset = PRESETS[opts.preset || monitor.preset] || PRESETS.standard
|
|
146
|
+
const since = opts.since || 0
|
|
147
|
+
const limit = opts.limit || 100
|
|
148
|
+
|
|
149
|
+
let filtered = monitor.entries.filter(e => e.ts >= since)
|
|
150
|
+
|
|
151
|
+
// Apply preset filtering
|
|
152
|
+
if (preset.errorsOnly) {
|
|
153
|
+
filtered = filtered.filter(e => e.s >= 400 || e.s === -1 || e.err)
|
|
154
|
+
}
|
|
155
|
+
if (!preset.showAssets) {
|
|
156
|
+
filtered = filtered.filter(e => !ASSET_TYPES.has(e.type))
|
|
157
|
+
}
|
|
158
|
+
if (!preset.showNoise) {
|
|
159
|
+
filtered = filtered.filter(e => !isNoise(e.url, monitor))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Apply custom include/exclude
|
|
163
|
+
if (monitor.includePatterns.length > 0) {
|
|
164
|
+
// Include patterns override noise filter
|
|
165
|
+
filtered = monitor.entries.filter(e =>
|
|
166
|
+
e.ts >= since && monitor.includePatterns.some(p => p.test(e.url))
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (monitor.excludePatterns.length > 0) {
|
|
170
|
+
filtered = filtered.filter(e => !monitor.excludePatterns.some(p => p.test(e.url)))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Most recent first, limited
|
|
174
|
+
filtered = filtered.slice(-limit)
|
|
175
|
+
|
|
176
|
+
const summary = buildSummary(monitor.entries.filter(e => e.ts >= since))
|
|
177
|
+
|
|
178
|
+
return { entries: filtered, summary }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get summary statistics.
|
|
183
|
+
*/
|
|
184
|
+
function getNetworkStats(wcId) {
|
|
185
|
+
const monitor = monitors.get(wcId)
|
|
186
|
+
if (!monitor) return { watching: false }
|
|
187
|
+
|
|
188
|
+
const entries = monitor.entries
|
|
189
|
+
const total = entries.length
|
|
190
|
+
const failed = entries.filter(e => e.s >= 400 || e.s === -1 || e.err).length
|
|
191
|
+
const pending = monitor.pending.size
|
|
192
|
+
const totalBytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0)
|
|
193
|
+
const avgTime = total > 0 ? Math.round(entries.reduce((sum, e) => sum + (e.t || 0), 0) / total) : 0
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
watching: true,
|
|
197
|
+
preset: monitor.preset,
|
|
198
|
+
total,
|
|
199
|
+
failed,
|
|
200
|
+
pending,
|
|
201
|
+
totalBytes,
|
|
202
|
+
avgTime,
|
|
203
|
+
summary: buildSummary(entries),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Clear the network buffer.
|
|
209
|
+
*/
|
|
210
|
+
function clearNetwork(wcId) {
|
|
211
|
+
const monitor = monitors.get(wcId)
|
|
212
|
+
if (!monitor) return
|
|
213
|
+
monitor.entries = []
|
|
214
|
+
monitor.pending.clear()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Check if a webContents is being monitored.
|
|
219
|
+
*/
|
|
220
|
+
function isMonitoring(wcId) {
|
|
221
|
+
return monitors.has(wcId)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============================================
|
|
225
|
+
// Internal: CDP event handling
|
|
226
|
+
// ============================================
|
|
227
|
+
|
|
228
|
+
function handleCdpEvent(monitor, method, params) {
|
|
229
|
+
switch (method) {
|
|
230
|
+
case 'Network.requestWillBeSent':
|
|
231
|
+
handleRequestStart(monitor, params)
|
|
232
|
+
break
|
|
233
|
+
case 'Network.responseReceived':
|
|
234
|
+
handleResponse(monitor, params)
|
|
235
|
+
break
|
|
236
|
+
case 'Network.loadingFinished':
|
|
237
|
+
handleLoadingFinished(monitor, params)
|
|
238
|
+
break
|
|
239
|
+
case 'Network.loadingFailed':
|
|
240
|
+
handleLoadingFailed(monitor, params)
|
|
241
|
+
break
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function handleRequestStart(monitor, params) {
|
|
246
|
+
const { requestId, request, redirectResponse, type, timestamp } = params
|
|
247
|
+
|
|
248
|
+
// If this is a redirect, update the existing entry
|
|
249
|
+
if (redirectResponse && monitor.pending.has(requestId)) {
|
|
250
|
+
const existing = monitor.pending.get(requestId)
|
|
251
|
+
existing.redirects = (existing.redirects || 0) + 1
|
|
252
|
+
existing.url = trimUrl(request.url)
|
|
253
|
+
existing.m = request.method
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const entry = {
|
|
258
|
+
id: requestId.slice(0, 8),
|
|
259
|
+
m: request.method,
|
|
260
|
+
url: trimUrl(request.url),
|
|
261
|
+
fullUrl: request.url,
|
|
262
|
+
s: 0, // pending
|
|
263
|
+
t: 0,
|
|
264
|
+
sz: '',
|
|
265
|
+
bytes: 0,
|
|
266
|
+
type: type || 'Other',
|
|
267
|
+
ts: Math.round(timestamp * 1000),
|
|
268
|
+
_startTime: timestamp,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
monitor.pending.set(requestId, entry)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function handleResponse(monitor, params) {
|
|
275
|
+
const { requestId, response } = params
|
|
276
|
+
const entry = monitor.pending.get(requestId)
|
|
277
|
+
if (!entry) return
|
|
278
|
+
|
|
279
|
+
entry.s = response.status
|
|
280
|
+
entry.mimeType = response.mimeType
|
|
281
|
+
|
|
282
|
+
// Detect CORS errors
|
|
283
|
+
if (response.status === 0 && response.headers?.['access-control-allow-origin'] === undefined) {
|
|
284
|
+
entry.cors = true
|
|
285
|
+
entry.err = 'CORS'
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function handleLoadingFinished(monitor, params) {
|
|
290
|
+
const { requestId, encodedDataLength, timestamp } = params
|
|
291
|
+
const entry = monitor.pending.get(requestId)
|
|
292
|
+
if (!entry) return
|
|
293
|
+
|
|
294
|
+
entry.bytes = encodedDataLength || 0
|
|
295
|
+
entry.sz = humanSize(entry.bytes)
|
|
296
|
+
entry.t = Math.round((timestamp - entry._startTime) * 1000)
|
|
297
|
+
delete entry._startTime
|
|
298
|
+
delete entry.fullUrl
|
|
299
|
+
|
|
300
|
+
finishEntry(monitor, requestId, entry)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function handleLoadingFailed(monitor, params) {
|
|
304
|
+
const { requestId, errorText, canceled, timestamp } = params
|
|
305
|
+
const entry = monitor.pending.get(requestId)
|
|
306
|
+
if (!entry) return
|
|
307
|
+
|
|
308
|
+
entry.s = -1
|
|
309
|
+
entry.err = canceled ? 'canceled' : (errorText || 'failed')
|
|
310
|
+
entry.t = Math.round((timestamp - entry._startTime) * 1000)
|
|
311
|
+
delete entry._startTime
|
|
312
|
+
delete entry.fullUrl
|
|
313
|
+
|
|
314
|
+
finishEntry(monitor, requestId, entry)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function finishEntry(monitor, requestId, entry) {
|
|
318
|
+
monitor.pending.delete(requestId)
|
|
319
|
+
monitor.entries.push(entry)
|
|
320
|
+
|
|
321
|
+
// Trim buffer
|
|
322
|
+
while (monitor.entries.length > monitor.maxBuffer) {
|
|
323
|
+
monitor.entries.shift()
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ============================================
|
|
328
|
+
// Internal: formatting helpers
|
|
329
|
+
// ============================================
|
|
330
|
+
|
|
331
|
+
function trimUrl(url) {
|
|
332
|
+
try {
|
|
333
|
+
const u = new URL(url)
|
|
334
|
+
let path = u.pathname
|
|
335
|
+
// Collapse long paths
|
|
336
|
+
if (path.length > 60) {
|
|
337
|
+
const parts = path.split('/')
|
|
338
|
+
if (parts.length > 4) {
|
|
339
|
+
path = '/' + parts[1] + '/.../' + parts[parts.length - 1]
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Trim query
|
|
343
|
+
let query = u.search
|
|
344
|
+
if (query.length > 30) {
|
|
345
|
+
query = query.slice(0, 27) + '...'
|
|
346
|
+
}
|
|
347
|
+
// Omit localhost origin
|
|
348
|
+
const origin = (u.hostname === 'localhost' || u.hostname === '127.0.0.1')
|
|
349
|
+
? '' : u.host
|
|
350
|
+
return (origin ? origin : '') + path + query
|
|
351
|
+
} catch {
|
|
352
|
+
return url.length > 80 ? url.slice(0, 77) + '...' : url
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function humanSize(bytes) {
|
|
357
|
+
if (bytes === 0) return '0B'
|
|
358
|
+
if (bytes < 1024) return bytes + 'B'
|
|
359
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1).replace(/\.0$/, '') + 'K'
|
|
360
|
+
return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '') + 'M'
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isNoise(url, monitor) {
|
|
364
|
+
return NOISE_PATTERNS.some(p => p.test(url))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function buildSummary(entries) {
|
|
368
|
+
const total = entries.length
|
|
369
|
+
const failed = entries.filter(e => e.s >= 400 || e.s === -1 || e.err).length
|
|
370
|
+
const totalBytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0)
|
|
371
|
+
const avgTime = total > 0 ? Math.round(entries.reduce((sum, e) => sum + (e.t || 0), 0) / total) : 0
|
|
372
|
+
const parts = [`${total} req`]
|
|
373
|
+
if (failed > 0) parts.push(`${failed} failed`)
|
|
374
|
+
parts.push(`${avgTime}ms avg`)
|
|
375
|
+
parts.push(humanSize(totalBytes))
|
|
376
|
+
return parts.join(', ')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = { attachNetwork, detachNetwork, getNetworkLog, getNetworkStats, clearNetwork, isMonitoring, humanSize }
|