@omg-dev/sdk 0.4.29 → 0.4.31
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/dist/OmgBadge-C559Dp1f.mjs +222 -0
- package/dist/brand/auto.mjs +1 -2
- package/dist/index.mjs +203 -23
- package/package.json +2 -7
- package/src/brand/OmgBadge.tsx +10 -194
- package/src/brand/auto.tsx +0 -9
- package/src/index.ts +0 -3
- package/src/sandbox.test.ts +4 -0
- package/src/sandbox.ts +1 -0
- package/dist/OmgBadge-CDnTIlnn.mjs +0 -371
- package/dist/VibesFeedback-BF2Vf6FK.mjs +0 -808
- package/dist/feedback/auto.mjs +0 -26
- package/src/feedback/VibesFeedback.tsx +0 -360
- package/src/feedback/auto.tsx +0 -47
- package/src/feedback/gestures.ts +0 -296
- package/src/feedback/index.ts +0 -18
- package/src/feedback/screenshot.ts +0 -37
- package/src/feedback/trace.ts +0 -166
package/src/feedback/gestures.ts
DELETED
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
// @omg-dev/sdk/feedback — gesture triggers for the feedback widget.
|
|
2
|
-
//
|
|
3
|
-
// Dependency-free. Detects the gestures a user can use to summon the feedback
|
|
4
|
-
// panel from anywhere in a running app:
|
|
5
|
-
//
|
|
6
|
-
// - "shake" device shake (accelerometer; iOS needs a permission grant)
|
|
7
|
-
// - "swipe-up" swipe IN from the bottom edge
|
|
8
|
-
// - "swipe-down" swipe IN from the top edge
|
|
9
|
-
// - "swipe-left" swipe IN from the right edge
|
|
10
|
-
// - "swipe-right" swipe IN from the left edge
|
|
11
|
-
// - "two-finger-press" press-and-hold with two fingers (no OS-gesture conflict)
|
|
12
|
-
//
|
|
13
|
-
// Edge swipes overlap with OS gestures on some platforms (iOS Safari: left/right
|
|
14
|
-
// edge = back/forward, bottom = home, top = control center). The default is the
|
|
15
|
-
// conflict-free, permission-free "two-finger-press" — the developer opts into
|
|
16
|
-
// edge swipes and "shake" explicitly. Every detector is a no-op on platforms
|
|
17
|
-
// that lack the relevant input (no touch / no motion), so enabling any is safe.
|
|
18
|
-
//
|
|
19
|
-
// "shake" is deliberately NOT a default: iOS gates the accelerometer behind a
|
|
20
|
-
// DeviceMotionEvent permission it refuses to persist across PWA launches, so a
|
|
21
|
-
// shake default forces users to re-grant motion access every session. The
|
|
22
|
-
// always-anchored feedback button (VibesFeedback `showButton`, on by default)
|
|
23
|
-
// is the reliable cross-platform summon; shake is an opt-in bonus.
|
|
24
|
-
|
|
25
|
-
import { useEffect, useRef, useState } from "react"
|
|
26
|
-
|
|
27
|
-
export type FeedbackGesture =
|
|
28
|
-
| "shake"
|
|
29
|
-
| "swipe-up"
|
|
30
|
-
| "swipe-down"
|
|
31
|
-
| "swipe-left"
|
|
32
|
-
| "swipe-right"
|
|
33
|
-
| "two-finger-press"
|
|
34
|
-
|
|
35
|
-
/** Default gestures when the host doesn't specify any. Permission-free and
|
|
36
|
-
* OS-gesture-safe — notably excludes "shake" (see the note above). */
|
|
37
|
-
export const DEFAULT_GESTURES: FeedbackGesture[] = ["two-finger-press"]
|
|
38
|
-
|
|
39
|
-
export type MotionPermission = "granted" | "denied" | "prompt" | "unsupported"
|
|
40
|
-
|
|
41
|
-
export interface GestureOptions {
|
|
42
|
-
/** Which gestures arm the trigger. Default: ["shake", "two-finger-press"]. */
|
|
43
|
-
gestures?: FeedbackGesture[]
|
|
44
|
-
/** Distance (px) from an edge a touch must START within to count as an edge swipe. Default 28. */
|
|
45
|
-
edgeSize?: number
|
|
46
|
-
/** Travel (px) a swipe must cover to fire. Default 90. */
|
|
47
|
-
swipeThreshold?: number
|
|
48
|
-
/** Max duration (ms) for a swipe — longer = a scroll/drag, ignored. Default 600. */
|
|
49
|
-
swipeMaxDuration?: number
|
|
50
|
-
/** Accelerometer delta that counts as a shake "jolt". Default 24. */
|
|
51
|
-
shakeThreshold?: number
|
|
52
|
-
/** Min hold (ms) for the two-finger press. Default 450. */
|
|
53
|
-
pressDuration?: number
|
|
54
|
-
/** Quiet period (ms) after a trigger before another can fire. Default 1200. */
|
|
55
|
-
cooldown?: number
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const DEFAULTS: Required<Omit<GestureOptions, "gestures">> = {
|
|
59
|
-
edgeSize: 28,
|
|
60
|
-
swipeThreshold: 90,
|
|
61
|
-
swipeMaxDuration: 600,
|
|
62
|
-
shakeThreshold: 24,
|
|
63
|
-
pressDuration: 450,
|
|
64
|
-
cooldown: 1200,
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function hasWindow(): boolean {
|
|
68
|
-
return typeof window !== "undefined" && typeof document !== "undefined"
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// DeviceMotionEvent.requestPermission exists only on iOS 13+ Safari.
|
|
72
|
-
type MotionCtor = {
|
|
73
|
-
requestPermission?: () => Promise<"granted" | "denied">
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function motionPermissionState(): MotionPermission {
|
|
77
|
-
if (!hasWindow() || typeof (window as unknown as { DeviceMotionEvent?: unknown }).DeviceMotionEvent === "undefined") {
|
|
78
|
-
return "unsupported"
|
|
79
|
-
}
|
|
80
|
-
const ctor = (window as unknown as { DeviceMotionEvent: MotionCtor }).DeviceMotionEvent
|
|
81
|
-
// If the gated API exists we must ask; otherwise motion is freely available.
|
|
82
|
-
return typeof ctor.requestPermission === "function" ? "prompt" : "granted"
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Ask for accelerometer access (iOS 13+). MUST be called from inside a user
|
|
87
|
-
* gesture handler (tap), or iOS rejects it. No-op elsewhere (returns true).
|
|
88
|
-
*/
|
|
89
|
-
export async function requestMotionPermission(): Promise<boolean> {
|
|
90
|
-
const state = motionPermissionState()
|
|
91
|
-
if (state === "granted") return true
|
|
92
|
-
if (state === "unsupported") return false
|
|
93
|
-
const ctor = (window as unknown as { DeviceMotionEvent: MotionCtor }).DeviceMotionEvent
|
|
94
|
-
try {
|
|
95
|
-
const res = await ctor.requestPermission!()
|
|
96
|
-
return res === "granted"
|
|
97
|
-
} catch {
|
|
98
|
-
return false
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Attach gesture listeners to the document. Returns a teardown function.
|
|
104
|
-
* `onTrigger` fires (debounced by `cooldown`) whenever any armed gesture
|
|
105
|
-
* completes. Pure DOM — usable without React.
|
|
106
|
-
*/
|
|
107
|
-
export function attachGestureListeners(onTrigger: () => void, options: GestureOptions = {}): () => void {
|
|
108
|
-
if (!hasWindow()) return () => {}
|
|
109
|
-
|
|
110
|
-
const opts = { ...DEFAULTS, ...options }
|
|
111
|
-
const gestures = new Set<FeedbackGesture>(options.gestures ?? DEFAULT_GESTURES)
|
|
112
|
-
const cleanups: Array<() => void> = []
|
|
113
|
-
|
|
114
|
-
let lastTrigger = 0
|
|
115
|
-
function fire() {
|
|
116
|
-
const now = Date.now()
|
|
117
|
-
if (now - lastTrigger < opts.cooldown) return
|
|
118
|
-
lastTrigger = now
|
|
119
|
-
// A short haptic confirms the gesture registered (mobile only).
|
|
120
|
-
try {
|
|
121
|
-
;(navigator as unknown as { vibrate?: (p: number) => void }).vibrate?.(12)
|
|
122
|
-
} catch {
|
|
123
|
-
/* no haptics */
|
|
124
|
-
}
|
|
125
|
-
onTrigger()
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── edge swipes ──────────────────────────────────────────────────────────
|
|
129
|
-
const wantsSwipe =
|
|
130
|
-
gestures.has("swipe-up") || gestures.has("swipe-down") || gestures.has("swipe-left") || gestures.has("swipe-right")
|
|
131
|
-
if (wantsSwipe) {
|
|
132
|
-
let sx = 0
|
|
133
|
-
let sy = 0
|
|
134
|
-
let st = 0
|
|
135
|
-
let fromEdge: "top" | "bottom" | "left" | "right" | null = null
|
|
136
|
-
|
|
137
|
-
const onStart = (e: TouchEvent) => {
|
|
138
|
-
if (e.touches.length !== 1) {
|
|
139
|
-
fromEdge = null
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
const t = e.touches[0]
|
|
143
|
-
sx = t.clientX
|
|
144
|
-
sy = t.clientY
|
|
145
|
-
st = Date.now()
|
|
146
|
-
const w = window.innerWidth
|
|
147
|
-
const h = window.innerHeight
|
|
148
|
-
fromEdge =
|
|
149
|
-
gestures.has("swipe-down") && sy <= opts.edgeSize
|
|
150
|
-
? "top"
|
|
151
|
-
: gestures.has("swipe-up") && sy >= h - opts.edgeSize
|
|
152
|
-
? "bottom"
|
|
153
|
-
: gestures.has("swipe-right") && sx <= opts.edgeSize
|
|
154
|
-
? "left"
|
|
155
|
-
: gestures.has("swipe-left") && sx >= w - opts.edgeSize
|
|
156
|
-
? "right"
|
|
157
|
-
: null
|
|
158
|
-
}
|
|
159
|
-
const onEnd = (e: TouchEvent) => {
|
|
160
|
-
if (!fromEdge) return
|
|
161
|
-
const edge = fromEdge
|
|
162
|
-
fromEdge = null
|
|
163
|
-
const t = e.changedTouches[0]
|
|
164
|
-
if (!t) return
|
|
165
|
-
const dx = t.clientX - sx
|
|
166
|
-
const dy = t.clientY - sy
|
|
167
|
-
if (Date.now() - st > opts.swipeMaxDuration) return
|
|
168
|
-
const passed =
|
|
169
|
-
(edge === "bottom" && -dy >= opts.swipeThreshold && Math.abs(dy) > Math.abs(dx)) ||
|
|
170
|
-
(edge === "top" && dy >= opts.swipeThreshold && Math.abs(dy) > Math.abs(dx)) ||
|
|
171
|
-
(edge === "right" && -dx >= opts.swipeThreshold && Math.abs(dx) > Math.abs(dy)) ||
|
|
172
|
-
(edge === "left" && dx >= opts.swipeThreshold && Math.abs(dx) > Math.abs(dy))
|
|
173
|
-
if (passed) fire()
|
|
174
|
-
}
|
|
175
|
-
document.addEventListener("touchstart", onStart, { passive: true })
|
|
176
|
-
document.addEventListener("touchend", onEnd, { passive: true })
|
|
177
|
-
cleanups.push(() => {
|
|
178
|
-
document.removeEventListener("touchstart", onStart)
|
|
179
|
-
document.removeEventListener("touchend", onEnd)
|
|
180
|
-
})
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ── two-finger press-and-hold ──────────────────────────────────────────────
|
|
184
|
-
if (gestures.has("two-finger-press")) {
|
|
185
|
-
let timer: ReturnType<typeof setTimeout> | null = null
|
|
186
|
-
const clear = () => {
|
|
187
|
-
if (timer) {
|
|
188
|
-
clearTimeout(timer)
|
|
189
|
-
timer = null
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
const onStart = (e: TouchEvent) => {
|
|
193
|
-
if (e.touches.length === 2) {
|
|
194
|
-
clear()
|
|
195
|
-
timer = setTimeout(fire, opts.pressDuration)
|
|
196
|
-
} else {
|
|
197
|
-
clear()
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
document.addEventListener("touchstart", onStart, { passive: true })
|
|
201
|
-
document.addEventListener("touchend", clear, { passive: true })
|
|
202
|
-
document.addEventListener("touchmove", clear, { passive: true })
|
|
203
|
-
document.addEventListener("touchcancel", clear, { passive: true })
|
|
204
|
-
cleanups.push(() => {
|
|
205
|
-
clear()
|
|
206
|
-
document.removeEventListener("touchstart", onStart)
|
|
207
|
-
document.removeEventListener("touchend", clear)
|
|
208
|
-
document.removeEventListener("touchmove", clear)
|
|
209
|
-
document.removeEventListener("touchcancel", clear)
|
|
210
|
-
})
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ── shake ──────────────────────────────────────────────────────────────────
|
|
214
|
-
if (gestures.has("shake")) {
|
|
215
|
-
let lx = 0
|
|
216
|
-
let ly = 0
|
|
217
|
-
let lz = 0
|
|
218
|
-
let seeded = false
|
|
219
|
-
let jolts = 0
|
|
220
|
-
let windowStart = 0
|
|
221
|
-
const onMotion = (e: DeviceMotionEvent) => {
|
|
222
|
-
const a = e.accelerationIncludingGravity
|
|
223
|
-
if (!a || a.x == null || a.y == null || a.z == null) return
|
|
224
|
-
if (!seeded) {
|
|
225
|
-
lx = a.x
|
|
226
|
-
ly = a.y
|
|
227
|
-
lz = a.z
|
|
228
|
-
seeded = true
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
const delta = Math.abs(a.x - lx) + Math.abs(a.y - ly) + Math.abs(a.z - lz)
|
|
232
|
-
lx = a.x
|
|
233
|
-
ly = a.y
|
|
234
|
-
lz = a.z
|
|
235
|
-
if (delta < opts.shakeThreshold) return
|
|
236
|
-
const now = Date.now()
|
|
237
|
-
if (now - windowStart > 1000) {
|
|
238
|
-
windowStart = now
|
|
239
|
-
jolts = 0
|
|
240
|
-
}
|
|
241
|
-
// A real shake is several jolts in quick succession, not one bump.
|
|
242
|
-
if (++jolts >= 3) {
|
|
243
|
-
jolts = 0
|
|
244
|
-
fire()
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
window.addEventListener("devicemotion", onMotion)
|
|
248
|
-
cleanups.push(() => window.removeEventListener("devicemotion", onMotion))
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return () => {
|
|
252
|
-
for (const c of cleanups) c()
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* React hook: arm the feedback gestures and call `onTrigger` when one fires.
|
|
258
|
-
* Returns the motion-permission state + a primer to request it (iOS).
|
|
259
|
-
*/
|
|
260
|
-
export function useFeedbackGesture(
|
|
261
|
-
onTrigger: () => void,
|
|
262
|
-
options: GestureOptions & { enabled?: boolean } = {},
|
|
263
|
-
): { motionPermission: MotionPermission; requestMotionPermission: () => Promise<boolean> } {
|
|
264
|
-
const cb = useRef(onTrigger)
|
|
265
|
-
cb.current = onTrigger
|
|
266
|
-
const [motionPermission, setMotionPermission] = useState<MotionPermission>(() => motionPermissionState())
|
|
267
|
-
|
|
268
|
-
const { enabled = true } = options
|
|
269
|
-
// Re-arm when the gesture SET or tuning changes — not on every render.
|
|
270
|
-
const sig = JSON.stringify({
|
|
271
|
-
g: options.gestures ?? DEFAULT_GESTURES,
|
|
272
|
-
e: options.edgeSize,
|
|
273
|
-
s: options.swipeThreshold,
|
|
274
|
-
d: options.swipeMaxDuration,
|
|
275
|
-
t: options.shakeThreshold,
|
|
276
|
-
p: options.pressDuration,
|
|
277
|
-
c: options.cooldown,
|
|
278
|
-
enabled,
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
useEffect(() => {
|
|
282
|
-
if (!enabled) return
|
|
283
|
-
const detach = attachGestureListeners(() => cb.current(), options)
|
|
284
|
-
return detach
|
|
285
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
286
|
-
}, [sig])
|
|
287
|
-
|
|
288
|
-
return {
|
|
289
|
-
motionPermission,
|
|
290
|
-
requestMotionPermission: async () => {
|
|
291
|
-
const ok = await requestMotionPermission()
|
|
292
|
-
setMotionPermission(ok ? "granted" : "denied")
|
|
293
|
-
return ok
|
|
294
|
-
},
|
|
295
|
-
}
|
|
296
|
-
}
|
package/src/feedback/index.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
// @omg-dev/sdk/feedback — gesture-summoned feedback widget + trace capture.
|
|
2
|
-
//
|
|
3
|
-
// Re-exported from the SDK root (`@omg-dev/sdk`) as well, because the publish
|
|
4
|
-
// workflow rewrites `exports` to only "." — subpath imports like
|
|
5
|
-
// `@omg-dev/sdk/feedback` don't resolve in production deploy builds.
|
|
6
|
-
|
|
7
|
-
export { VibesFeedback, type VibesFeedbackProps, type FeedbackReport } from "./VibesFeedback"
|
|
8
|
-
export {
|
|
9
|
-
useFeedbackGesture,
|
|
10
|
-
attachGestureListeners,
|
|
11
|
-
requestMotionPermission,
|
|
12
|
-
motionPermissionState,
|
|
13
|
-
type FeedbackGesture,
|
|
14
|
-
type GestureOptions,
|
|
15
|
-
type MotionPermission,
|
|
16
|
-
} from "./gestures"
|
|
17
|
-
export { installTrace, getTrace, clearTrace, type Breadcrumb, type BreadcrumbKind, type TraceOptions } from "./trace"
|
|
18
|
-
export { captureScreenshot, type ScreenshotOptions } from "./screenshot"
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// @omg-dev/sdk/feedback — client-side screenshot capture.
|
|
2
|
-
//
|
|
3
|
-
// Lazily imports `modern-screenshot` only when a capture is requested, so it's
|
|
4
|
-
// a separate chunk and never weighs down the app's main bundle. Rasterizes the
|
|
5
|
-
// live DOM (what the user is looking at) — the platform's server-side
|
|
6
|
-
// thumbnailer re-navigates the URL and loses the user's current state, so it
|
|
7
|
-
// can't be used for "capture the app in place".
|
|
8
|
-
//
|
|
9
|
-
// The widget's own nodes are tagged `data-vibes-feedback` and filtered out, so
|
|
10
|
-
// the panel/backdrop/button never appear in the shot.
|
|
11
|
-
|
|
12
|
-
export interface ScreenshotOptions {
|
|
13
|
-
/** Device-pixel scale, capped. Default min(devicePixelRatio, 2). */
|
|
14
|
-
scale?: number
|
|
15
|
-
/** Element to capture. Default document.body. */
|
|
16
|
-
target?: HTMLElement
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function captureScreenshot(options: ScreenshotOptions = {}): Promise<string | null> {
|
|
20
|
-
if (typeof document === "undefined" || typeof window === "undefined") return null
|
|
21
|
-
const target = options.target ?? document.body
|
|
22
|
-
if (!target) return null
|
|
23
|
-
try {
|
|
24
|
-
// Dynamic import → lazy chunk. Wrapped so a missing/older bundle that
|
|
25
|
-
// lacks the dep degrades to "no screenshot" instead of throwing.
|
|
26
|
-
const mod = (await import("modern-screenshot")) as {
|
|
27
|
-
domToPng: (node: HTMLElement, opts?: Record<string, unknown>) => Promise<string>
|
|
28
|
-
}
|
|
29
|
-
return await mod.domToPng(target, {
|
|
30
|
-
scale: options.scale ?? Math.min(window.devicePixelRatio || 1, 2),
|
|
31
|
-
backgroundColor: getComputedStyle(document.body).backgroundColor || "#ffffff",
|
|
32
|
-
filter: (node: Node) => !(node instanceof HTMLElement && node.hasAttribute("data-vibes-feedback")),
|
|
33
|
-
})
|
|
34
|
-
} catch {
|
|
35
|
-
return null
|
|
36
|
-
}
|
|
37
|
-
}
|
package/src/feedback/trace.ts
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
// @omg-dev/sdk/feedback — client trace ring buffer.
|
|
2
|
-
//
|
|
3
|
-
// Installs lightweight, low-overhead breadcrumb collectors so a feedback report
|
|
4
|
-
// can carry the recent runtime trace: console errors/warnings, uncaught errors,
|
|
5
|
-
// unhandled rejections, and network (fetch) failures + slow calls. Bounded ring
|
|
6
|
-
// buffer (default 50) so memory stays flat. install() is idempotent and returns
|
|
7
|
-
// a teardown; getTrace() snapshots the current buffer for a report.
|
|
8
|
-
//
|
|
9
|
-
// This mirrors the platform's existing vite-plugin error reporter (which
|
|
10
|
-
// postMessages `vibes:runtime-error` to the dashboard) but runs entirely inside
|
|
11
|
-
// the app so it works in published deploys too, not just the dev preview.
|
|
12
|
-
|
|
13
|
-
export type BreadcrumbKind = "console" | "error" | "rejection" | "network" | "nav"
|
|
14
|
-
|
|
15
|
-
export interface Breadcrumb {
|
|
16
|
-
at: number
|
|
17
|
-
kind: BreadcrumbKind
|
|
18
|
-
/** "error" | "warn" for console; HTTP method for network; etc. */
|
|
19
|
-
level?: string
|
|
20
|
-
message: string
|
|
21
|
-
/** Extra structured detail (url, status, durationMs, stack…). */
|
|
22
|
-
data?: Record<string, unknown>
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface Installed {
|
|
26
|
-
buffer: Breadcrumb[]
|
|
27
|
-
teardown: () => void
|
|
28
|
-
max: number
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let installed: Installed | null = null
|
|
32
|
-
|
|
33
|
-
function push(b: Breadcrumb) {
|
|
34
|
-
if (!installed) return
|
|
35
|
-
installed.buffer.push(b)
|
|
36
|
-
if (installed.buffer.length > installed.max) installed.buffer.shift()
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function safeString(v: unknown): string {
|
|
40
|
-
if (typeof v === "string") return v
|
|
41
|
-
if (v instanceof Error) return v.message
|
|
42
|
-
try {
|
|
43
|
-
return JSON.stringify(v)
|
|
44
|
-
} catch {
|
|
45
|
-
return String(v)
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface TraceOptions {
|
|
50
|
-
/** Max breadcrumbs retained. Default 50. */
|
|
51
|
-
max?: number
|
|
52
|
-
/** Patch console.error/warn. Default true. */
|
|
53
|
-
console?: boolean
|
|
54
|
-
/** Capture window error + unhandledrejection. Default true. */
|
|
55
|
-
errors?: boolean
|
|
56
|
-
/** Wrap fetch to record failures + slow (>2s) calls. Default true. */
|
|
57
|
-
network?: boolean
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Begin collecting breadcrumbs. Idempotent — a second call is a no-op. */
|
|
61
|
-
export function installTrace(options: TraceOptions = {}): () => void {
|
|
62
|
-
if (installed) return installed.teardown
|
|
63
|
-
if (typeof window === "undefined") return () => {}
|
|
64
|
-
|
|
65
|
-
const max = options.max ?? 50
|
|
66
|
-
const buffer: Breadcrumb[] = []
|
|
67
|
-
const cleanups: Array<() => void> = []
|
|
68
|
-
installed = { buffer, teardown: () => {}, max }
|
|
69
|
-
|
|
70
|
-
// ── console.error / console.warn ──────────────────────────────────────────
|
|
71
|
-
if (options.console !== false && typeof console !== "undefined") {
|
|
72
|
-
for (const level of ["error", "warn"] as const) {
|
|
73
|
-
const orig = console[level]
|
|
74
|
-
if (typeof orig !== "function") continue
|
|
75
|
-
console[level] = (...args: unknown[]) => {
|
|
76
|
-
push({ at: Date.now(), kind: "console", level, message: args.map(safeString).join(" ").slice(0, 1000) })
|
|
77
|
-
return (orig as (...a: unknown[]) => void).apply(console, args)
|
|
78
|
-
}
|
|
79
|
-
cleanups.push(() => {
|
|
80
|
-
console[level] = orig as typeof console.error
|
|
81
|
-
})
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ── uncaught errors + rejections ──────────────────────────────────────────
|
|
86
|
-
if (options.errors !== false) {
|
|
87
|
-
const onError = (e: ErrorEvent) => {
|
|
88
|
-
push({
|
|
89
|
-
at: Date.now(),
|
|
90
|
-
kind: "error",
|
|
91
|
-
message: e.message || "Uncaught error",
|
|
92
|
-
data: { source: e.filename, line: e.lineno, col: e.colno, stack: e.error?.stack?.slice(0, 2000) },
|
|
93
|
-
})
|
|
94
|
-
}
|
|
95
|
-
const onRejection = (e: PromiseRejectionEvent) => {
|
|
96
|
-
const r = e.reason as unknown
|
|
97
|
-
push({
|
|
98
|
-
at: Date.now(),
|
|
99
|
-
kind: "rejection",
|
|
100
|
-
message: safeString(r).slice(0, 1000),
|
|
101
|
-
data: { stack: (r as Error)?.stack?.slice(0, 2000) },
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
window.addEventListener("error", onError)
|
|
105
|
-
window.addEventListener("unhandledrejection", onRejection)
|
|
106
|
-
cleanups.push(() => {
|
|
107
|
-
window.removeEventListener("error", onError)
|
|
108
|
-
window.removeEventListener("unhandledrejection", onRejection)
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ── fetch failures + slow calls ───────────────────────────────────────────
|
|
113
|
-
if (options.network !== false && typeof window.fetch === "function") {
|
|
114
|
-
const origFetch = window.fetch.bind(window)
|
|
115
|
-
window.fetch = async (...args: Parameters<typeof fetch>) => {
|
|
116
|
-
const started = Date.now()
|
|
117
|
-
const url = typeof args[0] === "string" ? args[0] : (args[0] as Request)?.url ?? String(args[0])
|
|
118
|
-
const method = (args[1]?.method ?? (args[0] as Request)?.method ?? "GET").toUpperCase()
|
|
119
|
-
try {
|
|
120
|
-
const res = await origFetch(...args)
|
|
121
|
-
const durationMs = Date.now() - started
|
|
122
|
-
// Only breadcrumb the interesting cases — errors and slow calls — so we
|
|
123
|
-
// don't drown the buffer in routine 200s.
|
|
124
|
-
if (!res.ok || durationMs > 2000) {
|
|
125
|
-
push({
|
|
126
|
-
at: started,
|
|
127
|
-
kind: "network",
|
|
128
|
-
level: method,
|
|
129
|
-
message: `${res.status} ${method} ${url}`.slice(0, 500),
|
|
130
|
-
data: { status: res.status, durationMs },
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
|
-
return res
|
|
134
|
-
} catch (err) {
|
|
135
|
-
push({
|
|
136
|
-
at: started,
|
|
137
|
-
kind: "network",
|
|
138
|
-
level: method,
|
|
139
|
-
message: `FAILED ${method} ${url}`.slice(0, 500),
|
|
140
|
-
data: { error: safeString(err), durationMs: Date.now() - started },
|
|
141
|
-
})
|
|
142
|
-
throw err
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
cleanups.push(() => {
|
|
146
|
-
window.fetch = origFetch
|
|
147
|
-
})
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const teardown = () => {
|
|
151
|
-
for (const c of cleanups) c()
|
|
152
|
-
installed = null
|
|
153
|
-
}
|
|
154
|
-
installed.teardown = teardown
|
|
155
|
-
return teardown
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Snapshot the current breadcrumbs (most-recent last). Empty if not installed. */
|
|
159
|
-
export function getTrace(): Breadcrumb[] {
|
|
160
|
-
return installed ? [...installed.buffer] : []
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Clear the buffer without uninstalling (e.g. after a report is sent). */
|
|
164
|
-
export function clearTrace(): void {
|
|
165
|
-
if (installed) installed.buffer.length = 0
|
|
166
|
-
}
|