Repository navigation
Expand file tree
/
Copy patherrors.js
More file actions
114 lines (107 loc) · 4.34 KB
/
Copy patherrors.js
File metadata and controls
114 lines (107 loc) · 4.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// Errors → Desktop's agent. A page reports what went wrong to Desktop's error feed (`POST /gateways/desktop/ui/errors`),
// which a connected agent sees (its `recent_errors` tool, and the newest unacknowledged ones in `desktop_context`).
//
// import { captureErrors, reportError } from "./dim-app/source/errors.js"
// captureErrors() // uncaught errors + unhandled rejections, once per page
// reportError("Couldn't save the map", String(e)) // a handled failure the user (and agent) should know about
//
// The URL is relative to the app (`/apps/<name>/` → `../../gateways/desktop/ui/errors`). Reporting never throws, and a
// failed report is dropped (it never reports itself). Throttled: the same message at most once per 10 s, at most 20 reports a minute.
const SAME_MESSAGE_MS = 10_000
const PER_MINUTE = 20
/** @type {Map<string, number>} message → last sent (ms) */
const lastSent = new Map()
/** @type {number[]} send times in the last minute */
let recent = []
let installed = false
function appName() {
try {
const meta = document.querySelector('meta[name="dim-app"]')
if (meta?.content) {
return meta.content
}
const match = location.pathname.match(/^\/apps\/([^/]+)/)
return match ? decodeURIComponent(match[1]) : "app"
} catch {
return "app"
}
}
function allowed(message) {
const now = Date.now()
recent = recent.filter((at) => now - at < 60_000)
if (recent.length >= PER_MINUTE) {
return false
}
const last = lastSent.get(message)
if (last !== undefined && now - last < SAME_MESSAGE_MS) {
return false
}
lastSent.set(message, now)
if (lastSent.size > 200) {
lastSent.delete(lastSent.keys().next().value)
}
recent.push(now)
return true
}
/**
* Sends one error to Desktop's error feed. Resolves to `{ id, count }`, or null when it was throttled or couldn't be sent.
* @param {string} message one line, readable
* @param {unknown} [detail] a stack, a response body, anything (stringified)
* @param {{ level?: "error" | "warning", source?: string }} [options]
* @returns {Promise<{ id: number, count: number } | null>}
*/
export async function reportError(message, detail, options = {}) {
try {
const text = String(message ?? "").slice(0, 2000) || "error"
if (!allowed(text)) {
return null
}
const body = { source: options.source ?? appName(), message: text, level: options.level ?? "error" }
if (detail !== undefined && detail !== null && detail !== "") {
body.detail = (typeof detail === "string" ? detail : safeString(detail)).slice(0, 20_000)
}
const response = await fetch(new URL("../../gateways/desktop/ui/errors", location.href), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
})
return response.ok ? await response.json() : null
} catch {
return null
}
}
function safeString(value) {
try {
if (value instanceof Error) {
return value.stack || `${value.name}: ${value.message}`
}
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
/**
* Reports this page's uncaught errors and unhandled promise rejections. Idempotent: a second call does nothing.
* @param {{ source?: string }} [options]
*/
export function captureErrors(options = {}) {
if (installed || typeof globalThis.addEventListener !== "function") {
return
}
installed = true
globalThis.addEventListener("error", (event) => {
// a failed <img>/<script> load is an Event without a message; only script errors are reported
if (!event?.message) {
return
}
const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : ""
reportError(event.message, (event.error?.stack ?? "") + where, { source: options.source })
})
globalThis.addEventListener("unhandledrejection", (event) => {
const reason = event?.reason
const message = reason instanceof Error ? reason.message : String(reason)
reportError(`Unhandled rejection: ${message}`, reason instanceof Error ? reason.stack : undefined, {
source: options.source,
})
})
}