@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.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.
Files changed (220) hide show
  1. package/README.md +152 -18
  2. package/dist/react.js +516 -61
  3. package/dist/server.js +342 -14
  4. package/dist/types/capture/api.d.ts +30 -0
  5. package/dist/types/capture/editor.d.ts +10 -0
  6. package/dist/types/capture/ignore.d.ts +19 -0
  7. package/dist/types/capture/index.d.ts +1 -0
  8. package/dist/types/capture/picker.d.ts +9 -0
  9. package/dist/types/capture/picture.d.ts +29 -0
  10. package/dist/types/capture/surface.d.ts +39 -0
  11. package/dist/types/core/index.d.ts +2 -2
  12. package/dist/types/core/resolve.d.ts +2 -1
  13. package/dist/types/core/types.d.ts +11 -0
  14. package/dist/types/debug-context.d.ts +7 -0
  15. package/dist/types/dictation.d.ts +9 -0
  16. package/dist/types/embed/controller.d.ts +61 -0
  17. package/dist/types/embed/index.d.ts +1 -0
  18. package/dist/types/embed/options.d.ts +35 -0
  19. package/dist/types/help/index.d.ts +1 -0
  20. package/dist/types/help/panel-index.d.ts +1 -0
  21. package/dist/types/help/panel-styles.d.ts +1 -0
  22. package/dist/types/help/panel.d.ts +4 -0
  23. package/dist/types/help/pill-styles.d.ts +11 -0
  24. package/dist/types/help/pill.d.ts +44 -0
  25. package/dist/types/help/react.d.ts +12 -0
  26. package/dist/types/help/requests.d.ts +2 -0
  27. package/dist/types/help/types.d.ts +119 -0
  28. package/dist/types/help/wire.d.ts +61 -0
  29. package/dist/types/native-wire.d.ts +63 -0
  30. package/dist/types/native.d.ts +43 -0
  31. package/dist/types/react.d.ts +8 -1
  32. package/dist/types/replay.d.ts +6 -0
  33. package/dist/types/runtime/api.d.ts +250 -0
  34. package/dist/types/runtime/attachment-model.d.ts +16 -0
  35. package/dist/types/runtime/attachments.d.ts +43 -7
  36. package/dist/types/runtime/boot.d.ts +39 -0
  37. package/dist/types/runtime/browser-label.d.ts +5 -0
  38. package/dist/types/runtime/build-id.d.ts +7 -0
  39. package/dist/types/runtime/capture-ui.d.ts +45 -21
  40. package/dist/types/runtime/clarity-model.d.ts +260 -0
  41. package/dist/types/runtime/clarity-ui.d.ts +62 -0
  42. package/dist/types/runtime/client.d.ts +6 -197
  43. package/dist/types/runtime/composer-size.d.ts +30 -0
  44. package/dist/types/runtime/dashboard.d.ts +17 -2
  45. package/dist/types/runtime/debug-context-core.d.ts +88 -0
  46. package/dist/types/runtime/debug-context-host.d.ts +39 -0
  47. package/dist/types/runtime/debug-context-model.d.ts +352 -0
  48. package/dist/types/runtime/debug-context-redact.d.ts +48 -0
  49. package/dist/types/runtime/debug-context.d.ts +36 -0
  50. package/dist/types/runtime/details-model.d.ts +7 -0
  51. package/dist/types/runtime/details.d.ts +7 -5
  52. package/dist/types/runtime/dictation-engine.d.ts +98 -0
  53. package/dist/types/runtime/dictation-model.d.ts +28 -0
  54. package/dist/types/runtime/dictation-ui.d.ts +56 -0
  55. package/dist/types/runtime/dictation.d.ts +66 -0
  56. package/dist/types/runtime/draft-dock.d.ts +32 -0
  57. package/dist/types/runtime/draft-model.d.ts +104 -0
  58. package/dist/types/runtime/draft-store.d.ts +15 -0
  59. package/dist/types/runtime/edits.d.ts +1 -1
  60. package/dist/types/runtime/font.d.ts +16 -0
  61. package/dist/types/runtime/format.d.ts +30 -1
  62. package/dist/types/runtime/host-watch.d.ts +20 -0
  63. package/dist/types/runtime/icons.d.ts +16 -2
  64. package/dist/types/runtime/index.d.ts +3 -1
  65. package/dist/types/runtime/ink.d.ts +8 -0
  66. package/dist/types/runtime/kind-model.d.ts +14 -0
  67. package/dist/types/runtime/live-model.d.ts +25 -0
  68. package/dist/types/runtime/loader.d.ts +5 -11
  69. package/dist/types/runtime/markup-editor.d.ts +54 -0
  70. package/dist/types/runtime/markup-model.d.ts +126 -0
  71. package/dist/types/runtime/markup.d.ts +69 -0
  72. package/dist/types/runtime/mention-parse.d.ts +18 -0
  73. package/dist/types/runtime/mentions.d.ts +4 -4
  74. package/dist/types/runtime/page-background.d.ts +9 -0
  75. package/dist/types/runtime/page-image.d.ts +123 -0
  76. package/dist/types/runtime/placement.d.ts +6 -3
  77. package/dist/types/runtime/record.d.ts +18 -2
  78. package/dist/types/runtime/related.d.ts +1 -1
  79. package/dist/types/runtime/replay-model.d.ts +80 -0
  80. package/dist/types/runtime/replay.d.ts +5 -38
  81. package/dist/types/runtime/runtime.d.ts +6 -0
  82. package/dist/types/runtime/screen.d.ts +47 -0
  83. package/dist/types/runtime/screenshot.d.ts +60 -6
  84. package/dist/types/runtime/session-source.d.ts +45 -0
  85. package/dist/types/runtime/session.d.ts +20 -18
  86. package/dist/types/runtime/shield.d.ts +9 -0
  87. package/dist/types/runtime/shot.d.ts +45 -0
  88. package/dist/types/runtime/sketch.d.ts +26 -0
  89. package/dist/types/runtime/styles.d.ts +1 -1
  90. package/dist/types/runtime/sync.d.ts +1 -0
  91. package/dist/types/runtime/thread-model.d.ts +18 -0
  92. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  93. package/dist/types/runtime/ticket-form.d.ts +9 -0
  94. package/dist/types/runtime/toolbar.d.ts +37 -1
  95. package/dist/types/runtime/ui-scale.d.ts +17 -0
  96. package/dist/types/runtime/update.d.ts +37 -0
  97. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  98. package/dist/types/server/index.d.ts +22 -0
  99. package/dist/types/server/support.d.ts +20 -0
  100. package/dist/types/server.d.ts +2 -0
  101. package/dist/types/vite/plugin.d.ts +15 -0
  102. package/dist/vite.js +121 -41
  103. package/package.json +26 -13
  104. package/src/capture/api.ts +128 -0
  105. package/src/capture/editor.tsx +178 -0
  106. package/src/capture/ignore.ts +38 -0
  107. package/src/capture/index.ts +27 -0
  108. package/src/capture/picker.tsx +171 -0
  109. package/src/capture/picture.ts +90 -0
  110. package/src/capture/surface.tsx +185 -0
  111. package/src/compiler/annotate.ts +202 -0
  112. package/src/compiler/babel.d.ts +11 -0
  113. package/src/compiler/enabled.ts +55 -0
  114. package/src/compiler/index.ts +2 -0
  115. package/src/core/index.ts +8 -0
  116. package/src/core/resolve.ts +307 -0
  117. package/src/core/types.ts +58 -0
  118. package/src/debug-context.ts +47 -0
  119. package/src/dictation.ts +17 -0
  120. package/src/embed/controller.ts +215 -0
  121. package/src/embed/index.ts +68 -0
  122. package/src/embed/options.ts +100 -0
  123. package/src/help/index.ts +38 -0
  124. package/src/help/panel-index.ts +17 -0
  125. package/src/help/panel-styles.ts +123 -0
  126. package/src/help/panel.ts +840 -0
  127. package/src/help/pill-styles.ts +75 -0
  128. package/src/help/pill.ts +338 -0
  129. package/src/help/react.tsx +143 -0
  130. package/src/help/requests.ts +111 -0
  131. package/src/help/types.ts +124 -0
  132. package/src/help/wire.ts +55 -0
  133. package/src/native-wire.ts +124 -0
  134. package/src/native.ts +282 -0
  135. package/src/react.ts +27 -0
  136. package/src/replay.ts +13 -0
  137. package/src/runtime/api.ts +531 -0
  138. package/src/runtime/attachment-model.ts +42 -0
  139. package/src/runtime/attachments.tsx +1260 -0
  140. package/src/runtime/boot.ts +95 -0
  141. package/src/runtime/browser-label.ts +31 -0
  142. package/src/runtime/build-id.ts +10 -0
  143. package/src/runtime/capture-ui.tsx +306 -0
  144. package/src/runtime/capture.ts +508 -0
  145. package/src/runtime/clarity-model.ts +634 -0
  146. package/src/runtime/clarity-ui.tsx +454 -0
  147. package/src/runtime/client.ts +151 -0
  148. package/src/runtime/composer-size.tsx +146 -0
  149. package/src/runtime/dashboard.tsx +1130 -0
  150. package/src/runtime/debug-context-core.ts +681 -0
  151. package/src/runtime/debug-context-host.ts +56 -0
  152. package/src/runtime/debug-context-model.ts +1319 -0
  153. package/src/runtime/debug-context-redact.ts +259 -0
  154. package/src/runtime/debug-context.ts +613 -0
  155. package/src/runtime/details-model.ts +30 -0
  156. package/src/runtime/details.tsx +395 -0
  157. package/src/runtime/dictation-engine.ts +616 -0
  158. package/src/runtime/dictation-model.ts +79 -0
  159. package/src/runtime/dictation-ui.tsx +363 -0
  160. package/src/runtime/dictation.ts +271 -0
  161. package/src/runtime/dock.ts +108 -0
  162. package/src/runtime/draft-dock.tsx +124 -0
  163. package/src/runtime/draft-model.ts +251 -0
  164. package/src/runtime/draft-store.ts +149 -0
  165. package/src/runtime/edits.tsx +165 -0
  166. package/src/runtime/font.ts +42 -0
  167. package/src/runtime/format.ts +129 -0
  168. package/src/runtime/freshness.ts +23 -0
  169. package/src/runtime/handoff.ts +76 -0
  170. package/src/runtime/host-watch.ts +64 -0
  171. package/src/runtime/icons.tsx +295 -0
  172. package/src/runtime/idle.ts +20 -0
  173. package/src/runtime/index.ts +11 -0
  174. package/src/runtime/ink.ts +6 -0
  175. package/src/runtime/kind-model.ts +20 -0
  176. package/src/runtime/live-model.ts +32 -0
  177. package/src/runtime/loader.tsx +87 -0
  178. package/src/runtime/markup-editor.tsx +1333 -0
  179. package/src/runtime/markup-model.ts +485 -0
  180. package/src/runtime/markup.ts +382 -0
  181. package/src/runtime/members.ts +58 -0
  182. package/src/runtime/mention-parse.ts +56 -0
  183. package/src/runtime/mentions.tsx +205 -0
  184. package/src/runtime/page-background.ts +51 -0
  185. package/src/runtime/page-image.ts +1592 -0
  186. package/src/runtime/placement.ts +336 -0
  187. package/src/runtime/record.ts +402 -0
  188. package/src/runtime/redact.ts +30 -0
  189. package/src/runtime/related.ts +34 -0
  190. package/src/runtime/render-page.ts +2 -0
  191. package/src/runtime/replay-model.ts +232 -0
  192. package/src/runtime/replay.ts +173 -0
  193. package/src/runtime/runtime-version.ts +7 -0
  194. package/src/runtime/runtime.tsx +51 -0
  195. package/src/runtime/screen.ts +182 -0
  196. package/src/runtime/screenshot.ts +260 -0
  197. package/src/runtime/session-source.ts +112 -0
  198. package/src/runtime/session.ts +108 -0
  199. package/src/runtime/shield.ts +34 -0
  200. package/src/runtime/shot.tsx +422 -0
  201. package/src/runtime/sketch.ts +139 -0
  202. package/src/runtime/styles.ts +1541 -0
  203. package/src/runtime/sync.ts +300 -0
  204. package/src/runtime/thread-model.ts +71 -0
  205. package/src/runtime/ticket-form-model.ts +128 -0
  206. package/src/runtime/ticket-form.tsx +342 -0
  207. package/src/runtime/toolbar.tsx +4427 -0
  208. package/src/runtime/ui-scale.ts +75 -0
  209. package/src/runtime/update.ts +98 -0
  210. package/src/runtime/use-debug-context.ts +81 -0
  211. package/src/server/index.ts +133 -0
  212. package/src/server/support.ts +499 -0
  213. package/src/server.ts +4 -0
  214. package/src/vite/plugin.ts +102 -0
  215. package/src/vite.ts +1 -0
  216. package/dist/chunks/chunk-CSKXLF33.js +0 -153
  217. package/dist/chunks/chunk-HUM5FQII.js +0 -160
  218. package/dist/chunks/render-page-76MLIT6M.js +0 -1421
  219. package/dist/chunks/runtime-JX75TKER.js +0 -9267
  220. package/dist/chunks/screenshot-66ARTBGR.js +0 -12
@@ -0,0 +1,1319 @@
1
+ /**
2
+ * The debug context file (`context-<stamp>.json`): what a ticket carries for whoever fixes the bug, a person or
3
+ * a coding agent. DOM-free, so the web toolbar, native clients, the service's second redaction pass and the
4
+ * console all use the same shape, the same limits and the same brief.
5
+ *
6
+ * Every section is optional: a host turns each signal on or off in code (`DebugContextOptions`), and a reader
7
+ * says "not captured" rather than "nothing happened".
8
+ */
9
+ import {
10
+ newTally,
11
+ type JsonBag,
12
+ scrubId,
13
+ scrubText,
14
+ scrubUrl,
15
+ scrubValue,
16
+ type RedactionTally,
17
+ } from "./debug-context-redact.js";
18
+
19
+ export const DEBUG_CONTEXT_PREFIX = "context-";
20
+ /** The most a context file may weigh once serialized. Larger files are trimmed (`fitDebugContext`). */
21
+ export const DEBUG_CONTEXT_MAX_BYTES = 64 * 1024;
22
+ /** Rough characters-per-token for the brief's budget. */
23
+ const BRIEF_MAX_CHARS = 24_000;
24
+
25
+ /** On whenever a host turns debug context on. */
26
+ export const DEFAULT_SIGNALS = [
27
+ "errors",
28
+ "network",
29
+ "responseExcerpts",
30
+ "steps",
31
+ "environment",
32
+ "page",
33
+ "build",
34
+ "target",
35
+ "host",
36
+ "resources",
37
+ "lifecycle",
38
+ ] as const;
39
+
40
+ /** Off unless a host names them. */
41
+ export const OPT_IN_SIGNALS = [
42
+ "staleBuild",
43
+ "componentStacks",
44
+ "domExcerpt",
45
+ "queries",
46
+ "realtime",
47
+ "stores",
48
+ "storeValues",
49
+ "consoleLog",
50
+ "performance",
51
+ "storage",
52
+ "bodies",
53
+ ] as const;
54
+
55
+ export type DebugSignal = (typeof DEFAULT_SIGNALS)[number] | (typeof OPT_IN_SIGNALS)[number];
56
+ export const DEBUG_SIGNALS: readonly DebugSignal[] = [...DEFAULT_SIGNALS, ...OPT_IN_SIGNALS];
57
+
58
+ /**
59
+ * Who files the report. `reviewer`: people testing the app with the comment toolbar. `support`: the app's own
60
+ * customers through a help form, so failed-response excerpts start off.
61
+ */
62
+ export type DebugAudience = "reviewer" | "support";
63
+
64
+ export type FlagValue = string | number | boolean | null;
65
+
66
+ /** The host app's build. `deployed` is the build the server serves now, for spotting a stale tab. */
67
+ export type HostBuild = {
68
+ commit?: string;
69
+ version?: string;
70
+ env?: string;
71
+ builtAt?: string;
72
+ deployed?: string;
73
+ };
74
+
75
+ export type RealtimeState = {
76
+ status?: string;
77
+ /** Epoch ms of the last message received. */
78
+ lastMessageAt?: number;
79
+ reconnects?: number;
80
+ lastClose?: { code?: number; reason?: string };
81
+ };
82
+
83
+ /** What the host knows and the browser does not. Ids, never emails or names of people. */
84
+ export type HostContext = {
85
+ user?: { id?: string; role?: string; plan?: string; locale?: string };
86
+ org?: { id?: string; name?: string };
87
+ flags?: Record<string, FlagValue>;
88
+ build?: HostBuild;
89
+ /** The id the host's backend logs carry for this page or request chain (`traceparent`'s trace id). */
90
+ traceId?: string;
91
+ sessionId?: string;
92
+ /** The router's pattern for the page, e.g. `/projects/:id`. */
93
+ routePattern?: string;
94
+ realtime?: RealtimeState;
95
+ /** A few more facts of the host's own, at most 20. */
96
+ extra?: Record<string, FlagValue>;
97
+ };
98
+
99
+ /** One query of a data-fetching cache (TanStack Query, SWR): its state, never its data. */
100
+ export type QuerySummary = {
101
+ key: unknown;
102
+ status?: string;
103
+ fetchStatus?: string;
104
+ error?: unknown;
105
+ /** Epoch ms the data last changed. */
106
+ updatedAt?: number;
107
+ observers?: number;
108
+ };
109
+
110
+ /** How a host turns debug context on and what it allows. All of it is code, in the host app. */
111
+ export type DebugContextOptions = {
112
+ /** Default true once an options object is given. */
113
+ enabled?: boolean;
114
+ /** Default `reviewer` in the toolbar. */
115
+ audience?: DebugAudience;
116
+ /** Turns single signals on or off; anything not named keeps its default (`DEFAULT_SIGNALS`). */
117
+ signals?: Partial<Record<DebugSignal, boolean>>;
118
+ /** Static build facts. `getContext().build` wins field by field. */
119
+ build?: HostBuild;
120
+ /** Asked once per report, with a 300 ms limit. */
121
+ getContext?: () => HostContext | Promise<HostContext>;
122
+ /** The last word before the file leaves the browser: change it, or return null to send none. */
123
+ beforeCapture?: (file: DebugContextFile) => DebugContextFile | null;
124
+ /** Told the report's id, so the host can log it beside its own trace. */
125
+ onReport?: (reportId: string) => void;
126
+ /** Read for the `queries` signal. */
127
+ queries?: () => readonly QuerySummary[];
128
+ /** Read for the `stores` signal, by name. */
129
+ stores?: Record<string, () => unknown>;
130
+ /** More response headers that carry request ids. */
131
+ idHeaders?: readonly string[];
132
+ /** Requests whose full bodies are kept (the `bodies` signal): URL prefixes or patterns. */
133
+ bodyUrls?: readonly (string | RegExp)[];
134
+ /** `localStorage`/`sessionStorage` keys whose values are kept (the `storage` signal). Others: names only. */
135
+ storageValueKeys?: readonly string[];
136
+ /** A request slower than this is kept as slow. Default 3000. */
137
+ slowMs?: number;
138
+ };
139
+
140
+ /** `true`, `false`, or the options. Off when absent. */
141
+ export type DebugContextConfig = boolean | DebugContextOptions;
142
+
143
+ export type ResolvedDebugContext = {
144
+ audience: DebugAudience;
145
+ signals: Record<DebugSignal, boolean>;
146
+ options: DebugContextOptions;
147
+ slowMs: number;
148
+ };
149
+
150
+ /** The host's config as one shape, or null when debug context is off. */
151
+ export function resolveDebugContext(
152
+ config: DebugContextConfig | null | undefined,
153
+ fallbackAudience: DebugAudience = "reviewer",
154
+ ): ResolvedDebugContext | null {
155
+ // A script-tag host's value arrives unchecked: anything but `true` or a plain object is off.
156
+ if (config !== true && (typeof config !== "object" || config === null || Array.isArray(config)))
157
+ return null;
158
+ const options: DebugContextOptions = config === true ? {} : config;
159
+ if (options.enabled === false) return null;
160
+ const audience: DebugAudience =
161
+ options.audience === "support" || options.audience === "reviewer"
162
+ ? options.audience
163
+ : fallbackAudience;
164
+ const signals = {} as Record<DebugSignal, boolean>;
165
+ for (const signal of DEFAULT_SIGNALS) signals[signal] = true;
166
+ for (const signal of OPT_IN_SIGNALS) signals[signal] = false;
167
+ // Decision of 2026-11-12: excerpts on for reviewers, off for the support pill, switchable.
168
+ if (audience === "support") signals.responseExcerpts = false;
169
+ for (const signal of DEBUG_SIGNALS) {
170
+ const given = options.signals?.[signal];
171
+ if (typeof given === "boolean") signals[signal] = given;
172
+ }
173
+ if (!signals.stores) signals.storeValues = false;
174
+ const slowMs =
175
+ typeof options.slowMs === "number" && options.slowMs >= 100 && options.slowMs <= 120_000
176
+ ? options.slowMs
177
+ : 3000;
178
+ return { audience, signals, options, slowMs };
179
+ }
180
+
181
+ /**
182
+ * What, of a host's config, decides what is captured: a capture restarts only when this changes. Functions
183
+ * are left out; they are read live (`liveDebugOptions`).
184
+ */
185
+ export function debugContextKey(config: DebugContextConfig | null | undefined): string {
186
+ const resolved = resolveDebugContext(config ?? false);
187
+ if (!resolved) return "off";
188
+ const { options } = resolved;
189
+ return JSON.stringify([
190
+ resolved.audience,
191
+ resolved.signals,
192
+ resolved.slowMs,
193
+ options.idHeaders ?? [],
194
+ (options.bodyUrls ?? []).map(String),
195
+ options.storageValueKeys ?? [],
196
+ Object.keys(options.stores ?? {}),
197
+ options.build ?? null,
198
+ ]);
199
+ }
200
+
201
+ /**
202
+ * The options as they are now, with every function reading `read()` when it is called: a host that passes a
203
+ * new options object on every render keeps one running capture that still calls its latest functions.
204
+ */
205
+ export function liveDebugOptions(
206
+ read: () => DebugContextConfig | null | undefined,
207
+ ): DebugContextOptions {
208
+ const now = () => {
209
+ const value = read();
210
+ return value && typeof value === "object" ? value : undefined;
211
+ };
212
+ const first = now() ?? {};
213
+ return {
214
+ ...first,
215
+ ...(first.getContext ? { getContext: () => now()?.getContext?.() ?? {} } : {}),
216
+ ...(first.beforeCapture
217
+ ? {
218
+ beforeCapture: (file: DebugContextFile) => {
219
+ const current = now()?.beforeCapture;
220
+ return current ? current(file) : file;
221
+ },
222
+ }
223
+ : {}),
224
+ ...(first.onReport ? { onReport: (id: string) => now()?.onReport?.(id) } : {}),
225
+ ...(first.queries ? { queries: () => now()?.queries?.() ?? [] } : {}),
226
+ ...(first.stores
227
+ ? {
228
+ stores: Object.fromEntries(
229
+ Object.keys(first.stores).map((name) => [name, () => now()?.stores?.[name]?.()]),
230
+ ),
231
+ }
232
+ : {}),
233
+ };
234
+ }
235
+
236
+ // ---------------------------------------------------------------- the file
237
+
238
+ export type DebugErrorKind =
239
+ | "uncaught"
240
+ | "rejection"
241
+ | "console"
242
+ | "boundary"
243
+ | "resource"
244
+ | "csp"
245
+ | "reported";
246
+
247
+ export type DebugError = {
248
+ /** Milliseconds before the report (negative). */
249
+ t: number;
250
+ kind: DebugErrorKind;
251
+ level: "error" | "warn";
252
+ message: string;
253
+ stack?: string[];
254
+ /** `file:line:col` where the browser said it happened. */
255
+ source?: string;
256
+ componentStack?: string[];
257
+ count: number;
258
+ };
259
+
260
+ export type DebugRequest = {
261
+ t: number;
262
+ method: string;
263
+ url: string;
264
+ /** 0 when there was no answer. */
265
+ status: number;
266
+ ms?: number;
267
+ bytes?: number;
268
+ contentType?: string;
269
+ excerpt?: string;
270
+ graphql?: { operation?: string; errors: string[] };
271
+ ids?: Record<string, string>;
272
+ requestBody?: string;
273
+ responseBody?: string;
274
+ };
275
+
276
+ export type DebugStep =
277
+ | { t: number; type: "navigate"; path: string }
278
+ | { t: number; type: "click"; label: string }
279
+ | { t: number; type: "input"; label: string; redacted: boolean; value?: string }
280
+ | { t: number; type: "console"; level: "error" | "warn"; message: string }
281
+ | { t: number; type: "network"; method: string; path: string; status: number }
282
+ | { t: number; type: "say"; text: string };
283
+
284
+ export type LifecycleEvent =
285
+ | "online"
286
+ | "offline"
287
+ | "hidden"
288
+ | "visible"
289
+ | "foreground"
290
+ | "background";
291
+
292
+ export type DebugContextFile = {
293
+ v: 1;
294
+ kind: "debug-context";
295
+ capturedAt: string;
296
+ audience: DebugAudience;
297
+ platform: "web" | "ios" | "android";
298
+ page?: {
299
+ url: string;
300
+ route: string;
301
+ routePattern?: string;
302
+ title?: string;
303
+ referrer?: string;
304
+ loadedAgoMs?: number;
305
+ softNavigations?: number;
306
+ visibility?: string;
307
+ };
308
+ build?: { host?: HostBuild; toolbar?: string; staleHost?: boolean };
309
+ client?: {
310
+ browser?: string;
311
+ os?: string;
312
+ device?: string;
313
+ viewport?: [number, number];
314
+ dpr?: number;
315
+ zoom?: number;
316
+ locale?: string;
317
+ timezone?: string;
318
+ colorScheme?: string;
319
+ reducedMotion?: boolean;
320
+ online?: boolean;
321
+ connection?: string;
322
+ touch?: boolean;
323
+ appVersion?: string;
324
+ appBuild?: string;
325
+ appVariant?: string;
326
+ };
327
+ identity?: {
328
+ userId?: string;
329
+ role?: string;
330
+ plan?: string;
331
+ locale?: string;
332
+ orgId?: string;
333
+ orgName?: string;
334
+ };
335
+ flags?: Record<string, FlagValue>;
336
+ extra?: Record<string, FlagValue>;
337
+ target?: {
338
+ component?: string;
339
+ file?: string;
340
+ stack?: string[];
341
+ role?: string;
342
+ name?: string;
343
+ tag?: string;
344
+ html?: string;
345
+ box?: [number, number, number, number];
346
+ styles?: Record<string, string>;
347
+ };
348
+ errors?: DebugError[];
349
+ logs?: { t: number; level: string; message: string }[];
350
+ network?: {
351
+ failed: DebugRequest[];
352
+ slow: DebugRequest[];
353
+ pending: DebugRequest[];
354
+ aborted: number;
355
+ };
356
+ steps?: DebugStep[];
357
+ lifecycle?: { t: number; event: LifecycleEvent }[];
358
+ performance?: {
359
+ longTasks?: number;
360
+ longestTaskMs?: number;
361
+ inpMs?: number;
362
+ lcpMs?: number;
363
+ cls?: number;
364
+ loadMs?: number;
365
+ heapMb?: number;
366
+ };
367
+ queries?: {
368
+ key: string;
369
+ status?: string;
370
+ fetchStatus?: string;
371
+ error?: string;
372
+ updatedAgoMs?: number;
373
+ observers?: number;
374
+ }[];
375
+ stores?: JsonBag;
376
+ storage?: {
377
+ local?: { key: string; bytes: number; value?: string }[];
378
+ session?: { key: string; bytes: number; value?: string }[];
379
+ cookies?: string[];
380
+ indexedDb?: string[];
381
+ };
382
+ realtime?: {
383
+ status?: string;
384
+ lastMessageAgoMs?: number;
385
+ reconnects?: number;
386
+ lastClose?: { code?: number; reason?: string };
387
+ };
388
+ join: { reportId: string; traceId?: string; sessionId?: string; requestIds?: string[] };
389
+ /** Which signals were on, so a reader can tell "off" from "nothing happened". */
390
+ signals: DebugSignal[];
391
+ redaction: { rules: 1; masked: number; dropped: number; truncated: boolean };
392
+ };
393
+
394
+ export function isDebugContextName(name: string) {
395
+ return /^context-[A-Za-z0-9._-]{1,80}\.json$/.test(name);
396
+ }
397
+
398
+ /** `context-<stamp>.json` for a report taken at `at`. */
399
+ export function debugContextName(at = new Date()) {
400
+ return `${DEBUG_CONTEXT_PREFIX}${at.toISOString().slice(0, 19).replace(/[:T]/g, "-")}.json`;
401
+ }
402
+
403
+ /** A report id: sortable by time, unguessable, safe in a header or a log line. */
404
+ export function newReportId(now = Date.now()) {
405
+ const random = new Uint8Array(8);
406
+ // React Native without a crypto polyfill has no getRandomValues; the id only needs to be hard to guess.
407
+ const source = (
408
+ globalThis as { crypto?: { getRandomValues?: (array: Uint8Array) => Uint8Array } }
409
+ ).crypto;
410
+ if (typeof source?.getRandomValues === "function") source.getRandomValues(random);
411
+ else
412
+ for (let index = 0; index < random.length; index += 1)
413
+ random[index] = Math.floor(Math.random() * 256);
414
+ return `ctx_${now.toString(36)}${[...random].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
415
+ }
416
+
417
+ // ---------------------------------------------------------------- reading an untrusted file
418
+
419
+ type Bag = { readonly [key: string]: unknown };
420
+
421
+ function bag(value: unknown): Bag | null {
422
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Bag) : null;
423
+ }
424
+
425
+ function str(value: unknown, max: number, tally: RedactionTally): string | undefined {
426
+ if (typeof value !== "string") return undefined;
427
+ const text = scrubText(value, max, tally).trim();
428
+ return text || undefined;
429
+ }
430
+
431
+ function num(value: unknown, min = -1e12, max = 1e12): number | undefined {
432
+ return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max
433
+ ? Math.round(value * 1000) / 1000
434
+ : undefined;
435
+ }
436
+
437
+ function bool(value: unknown): boolean | undefined {
438
+ return typeof value === "boolean" ? value : undefined;
439
+ }
440
+
441
+ function list<T>(value: unknown, max: number, read: (item: unknown) => T | undefined): T[] {
442
+ if (!Array.isArray(value)) return [];
443
+ const out: T[] = [];
444
+ for (const item of value) {
445
+ if (out.length >= max) break;
446
+ const read1 = read(item);
447
+ if (read1 !== undefined) out.push(read1);
448
+ }
449
+ return out;
450
+ }
451
+
452
+ /** `T` with no property holding `undefined`: what `compact` returns. */
453
+ type Defined<T> = { [K in keyof T]?: Exclude<T[K], undefined> };
454
+
455
+ /** The object without its `undefined` properties, or undefined when none is left. */
456
+ function compact<T extends object>(value: T): Defined<T> | undefined {
457
+ const entries = Object.entries(value).filter(([, item]) => item !== undefined);
458
+ return entries.length ? (Object.fromEntries(entries) as Defined<T>) : undefined;
459
+ }
460
+
461
+ /** A copy without `key`. */
462
+ function without<T extends object, K extends keyof T>(value: T, key: K): Omit<T, K> {
463
+ const { [key]: _dropped, ...rest } = value;
464
+ return rest;
465
+ }
466
+
467
+ const SAFE_ID = /^[A-Za-z0-9_.:-]{1,120}$/;
468
+
469
+ function flagRecord(
470
+ value: unknown,
471
+ max: number,
472
+ tally: RedactionTally,
473
+ ): Record<string, FlagValue> | undefined {
474
+ const input = bag(value);
475
+ if (!input) return undefined;
476
+ const out: Record<string, FlagValue> = {};
477
+ let count = 0;
478
+ for (const [key, item] of Object.entries(input)) {
479
+ if (count >= max) break;
480
+ const name = scrubText(key, 80, tally);
481
+ if (!name) continue;
482
+ if (typeof item === "string") out[name] = scrubText(item, 120, tally);
483
+ else if (typeof item === "number" && Number.isFinite(item)) out[name] = item;
484
+ else if (typeof item === "boolean" || item === null) out[name] = item;
485
+ else continue;
486
+ count += 1;
487
+ }
488
+ return count ? out : undefined;
489
+ }
490
+
491
+ function hostBuild(value: unknown, tally: RedactionTally): HostBuild | undefined {
492
+ const input = bag(value);
493
+ if (!input) return undefined;
494
+ return compact({
495
+ commit: str(input["commit"], 64, tally),
496
+ version: str(input["version"], 64, tally),
497
+ env: str(input["env"], 40, tally),
498
+ builtAt: str(input["builtAt"], 40, tally),
499
+ deployed: str(input["deployed"], 64, tally),
500
+ });
501
+ }
502
+
503
+ const ERROR_KINDS: readonly DebugErrorKind[] = [
504
+ "uncaught",
505
+ "rejection",
506
+ "console",
507
+ "boundary",
508
+ "resource",
509
+ "csp",
510
+ "reported",
511
+ ];
512
+
513
+ function readError(value: unknown, tally: RedactionTally): DebugError | undefined {
514
+ const input = bag(value);
515
+ if (!input) return undefined;
516
+ const message = str(input["message"], 500, tally);
517
+ const kind = ERROR_KINDS.find((item) => item === input["kind"]);
518
+ if (!message || !kind) return undefined;
519
+ const stack = list(input["stack"], 12, (line) => str(line, 240, tally));
520
+ const componentStack = list(input["componentStack"], 12, (line) => str(line, 120, tally));
521
+ return {
522
+ t: num(input["t"]) ?? 0,
523
+ kind,
524
+ level: input["level"] === "warn" ? "warn" : "error",
525
+ message,
526
+ ...(stack.length ? { stack } : {}),
527
+ ...(str(input["source"], 240, tally) ? { source: str(input["source"], 240, tally)! } : {}),
528
+ ...(componentStack.length ? { componentStack } : {}),
529
+ count: Math.max(1, Math.min(10_000, Math.round(num(input["count"]) ?? 1))),
530
+ };
531
+ }
532
+
533
+ function readIds(value: unknown): Record<string, string> | undefined {
534
+ const input = bag(value);
535
+ if (!input) return undefined;
536
+ const out: Record<string, string> = {};
537
+ let count = 0;
538
+ for (const [name, item] of Object.entries(input)) {
539
+ if (count >= 10) break;
540
+ if (!/^[a-z0-9-]{1,64}$/.test(name) || typeof item !== "string") continue;
541
+ const id = scrubId(item);
542
+ if (!id) continue;
543
+ out[name] = id;
544
+ count += 1;
545
+ }
546
+ return count ? out : undefined;
547
+ }
548
+
549
+ function readRequest(value: unknown, tally: RedactionTally): DebugRequest | undefined {
550
+ const input = bag(value);
551
+ if (!input) return undefined;
552
+ const url = typeof input["url"] === "string" ? scrubUrl(input["url"], 400, undefined, tally) : "";
553
+ if (!url) return undefined;
554
+ const method =
555
+ typeof input["method"] === "string"
556
+ ? input["method"]
557
+ .toUpperCase()
558
+ .replace(/[^A-Z]/g, "")
559
+ .slice(0, 10)
560
+ : "";
561
+ const graphqlIn = bag(input["graphql"]);
562
+ const graphqlErrors = graphqlIn
563
+ ? list(graphqlIn["errors"], 5, (item) => str(item, 300, tally))
564
+ : [];
565
+ const operation = graphqlIn ? str(graphqlIn["operation"], 80, tally) : undefined;
566
+ return {
567
+ t: num(input["t"]) ?? 0,
568
+ method: method || "GET",
569
+ url,
570
+ status: Math.round(num(input["status"], 0, 999) ?? 0),
571
+ ...(num(input["ms"], 0, 3_600_000) !== undefined
572
+ ? { ms: Math.round(num(input["ms"], 0, 3_600_000)!) }
573
+ : {}),
574
+ ...(num(input["bytes"], 0, 1e10) !== undefined
575
+ ? { bytes: Math.round(num(input["bytes"], 0, 1e10)!) }
576
+ : {}),
577
+ ...(str(input["contentType"], 80, tally)
578
+ ? { contentType: str(input["contentType"], 80, tally)! }
579
+ : {}),
580
+ ...(str(input["excerpt"], 2048, tally) ? { excerpt: str(input["excerpt"], 2048, tally)! } : {}),
581
+ ...(graphqlIn && (graphqlErrors.length || operation)
582
+ ? { graphql: { ...(operation ? { operation } : {}), errors: graphqlErrors } }
583
+ : {}),
584
+ ...(readIds(input["ids"]) ? { ids: readIds(input["ids"])! } : {}),
585
+ ...(str(input["requestBody"], 16_384, tally)
586
+ ? { requestBody: str(input["requestBody"], 16_384, tally)! }
587
+ : {}),
588
+ ...(str(input["responseBody"], 16_384, tally)
589
+ ? { responseBody: str(input["responseBody"], 16_384, tally)! }
590
+ : {}),
591
+ };
592
+ }
593
+
594
+ function readStep(value: unknown, tally: RedactionTally): DebugStep | undefined {
595
+ const input = bag(value);
596
+ if (!input) return undefined;
597
+ const t = num(input["t"]) ?? 0;
598
+ switch (input["type"]) {
599
+ case "navigate": {
600
+ const path =
601
+ typeof input["path"] === "string"
602
+ ? scrubUrl(input["path"], 300, "https://app.invalid", tally)
603
+ : "";
604
+ return path ? { t, type: "navigate", path } : undefined;
605
+ }
606
+ case "click": {
607
+ const label = str(input["label"], 120, tally);
608
+ return label ? { t, type: "click", label } : undefined;
609
+ }
610
+ case "input": {
611
+ const label = str(input["label"], 120, tally);
612
+ if (!label) return undefined;
613
+ const entered = input["redacted"] === true ? undefined : str(input["value"], 80, tally);
614
+ return {
615
+ t,
616
+ type: "input",
617
+ label,
618
+ redacted: !entered,
619
+ ...(entered ? { value: entered } : {}),
620
+ };
621
+ }
622
+ case "console": {
623
+ const message = str(input["message"], 300, tally);
624
+ return message
625
+ ? { t, type: "console", level: input["level"] === "warn" ? "warn" : "error", message }
626
+ : undefined;
627
+ }
628
+ case "network": {
629
+ const path =
630
+ typeof input["path"] === "string"
631
+ ? scrubUrl(input["path"], 300, "https://app.invalid", tally)
632
+ : "";
633
+ if (!path) return undefined;
634
+ const method =
635
+ typeof input["method"] === "string"
636
+ ? input["method"]
637
+ .toUpperCase()
638
+ .replace(/[^A-Z]/g, "")
639
+ .slice(0, 10)
640
+ : "GET";
641
+ return {
642
+ t,
643
+ type: "network",
644
+ method: method || "GET",
645
+ path,
646
+ status: Math.round(num(input["status"], 0, 999) ?? 0),
647
+ };
648
+ }
649
+ case "say": {
650
+ const text = str(input["text"], 400, tally);
651
+ return text ? { t, type: "say", text } : undefined;
652
+ }
653
+ default:
654
+ return undefined;
655
+ }
656
+ }
657
+
658
+ const LIFECYCLE: readonly LifecycleEvent[] = [
659
+ "online",
660
+ "offline",
661
+ "hidden",
662
+ "visible",
663
+ "foreground",
664
+ "background",
665
+ ];
666
+
667
+ function storageList(value: unknown, tally: RedactionTally) {
668
+ const out = list(value, 100, (item) => {
669
+ const input = bag(item);
670
+ const key = input ? str(input["key"], 120, tally) : undefined;
671
+ if (!input || !key) return undefined;
672
+ const entry: { key: string; bytes: number; value?: string } = {
673
+ key,
674
+ bytes: Math.round(num(input["bytes"], 0, 1e9) ?? 0),
675
+ };
676
+ const kept = str(input["value"], 500, tally);
677
+ if (kept) entry.value = kept;
678
+ return entry;
679
+ });
680
+ return out.length ? out : undefined;
681
+ }
682
+
683
+ /**
684
+ * A context file rebuilt from untrusted JSON: only known fields, every string redacted again, every list and
685
+ * string bounded. Null when it is not a context file. Running it on its own output changes nothing but the
686
+ * counts, so the browser, the service and the console can all run it.
687
+ */
688
+ export function sanitizeDebugContext(value: unknown): DebugContextFile | null {
689
+ const input = bag(value);
690
+ if (!input || input["v"] !== 1 || input["kind"] !== "debug-context") return null;
691
+ const tally = newTally();
692
+ const before = bag(input["redaction"]);
693
+ const joinIn = bag(input["join"]);
694
+ const reportId =
695
+ typeof joinIn?.["reportId"] === "string" && SAFE_ID.test(joinIn["reportId"])
696
+ ? joinIn["reportId"]
697
+ : null;
698
+ if (!reportId) return null;
699
+ const capturedAt =
700
+ typeof input["capturedAt"] === "string" && !Number.isNaN(Date.parse(input["capturedAt"]))
701
+ ? new Date(Date.parse(input["capturedAt"])).toISOString()
702
+ : null;
703
+ if (!capturedAt) return null;
704
+ const file: DebugContextFile = {
705
+ v: 1,
706
+ kind: "debug-context",
707
+ capturedAt,
708
+ audience: input["audience"] === "support" ? "support" : "reviewer",
709
+ platform:
710
+ input["platform"] === "ios" || input["platform"] === "android" ? input["platform"] : "web",
711
+ join: { reportId },
712
+ signals: list(input["signals"], DEBUG_SIGNALS.length, (item) =>
713
+ DEBUG_SIGNALS.find((signal) => signal === item),
714
+ ),
715
+ redaction: { rules: 1, masked: 0, dropped: 0, truncated: before?.["truncated"] === true },
716
+ };
717
+
718
+ const page = bag(input["page"]);
719
+ if (page) {
720
+ const url =
721
+ typeof page["url"] === "string" ? scrubUrl(page["url"], 1000, undefined, tally) : "";
722
+ const route =
723
+ typeof page["route"] === "string"
724
+ ? scrubUrl(page["route"], 300, "https://app.invalid", tally)
725
+ : "";
726
+ if (url || route) {
727
+ file.page = {
728
+ url,
729
+ route,
730
+ ...compact({
731
+ routePattern: str(page["routePattern"], 200, tally),
732
+ title: str(page["title"], 160, tally),
733
+ referrer:
734
+ typeof page["referrer"] === "string" && page["referrer"]
735
+ ? scrubUrl(page["referrer"], 300, undefined, tally)
736
+ : undefined,
737
+ loadedAgoMs: num(page["loadedAgoMs"], 0, 1e11),
738
+ softNavigations: num(page["softNavigations"], 0, 1e6),
739
+ visibility: str(page["visibility"], 20, tally),
740
+ }),
741
+ };
742
+ }
743
+ }
744
+
745
+ const build = bag(input["build"]);
746
+ if (build) {
747
+ const kept = compact({
748
+ host: hostBuild(build["host"], tally),
749
+ toolbar: str(build["toolbar"], 80, tally),
750
+ staleHost: bool(build["staleHost"]),
751
+ });
752
+ if (kept) file.build = kept;
753
+ }
754
+
755
+ const client = bag(input["client"]);
756
+ if (client) {
757
+ const viewport =
758
+ Array.isArray(client["viewport"]) &&
759
+ client["viewport"].length === 2 &&
760
+ num(client["viewport"][0], 0, 100_000) !== undefined &&
761
+ num(client["viewport"][1], 0, 100_000) !== undefined
762
+ ? ([
763
+ Math.round(client["viewport"][0] as number),
764
+ Math.round(client["viewport"][1] as number),
765
+ ] as [number, number])
766
+ : undefined;
767
+ const kept = compact({
768
+ browser: str(client["browser"], 80, tally),
769
+ os: str(client["os"], 80, tally),
770
+ device: str(client["device"], 80, tally),
771
+ viewport,
772
+ dpr: num(client["dpr"], 0, 16),
773
+ zoom: num(client["zoom"], 0, 16),
774
+ locale: str(client["locale"], 40, tally),
775
+ timezone: str(client["timezone"], 60, tally),
776
+ colorScheme: str(client["colorScheme"], 20, tally),
777
+ reducedMotion: bool(client["reducedMotion"]),
778
+ online: bool(client["online"]),
779
+ connection: str(client["connection"], 20, tally),
780
+ touch: bool(client["touch"]),
781
+ appVersion: str(client["appVersion"], 40, tally),
782
+ appBuild: str(client["appBuild"], 40, tally),
783
+ appVariant: str(client["appVariant"], 40, tally),
784
+ });
785
+ if (kept) file.client = kept;
786
+ }
787
+
788
+ const identity = bag(input["identity"]);
789
+ if (identity) {
790
+ const kept = compact({
791
+ userId: str(identity["userId"], 120, tally),
792
+ role: str(identity["role"], 60, tally),
793
+ plan: str(identity["plan"], 60, tally),
794
+ locale: str(identity["locale"], 40, tally),
795
+ orgId: str(identity["orgId"], 120, tally),
796
+ orgName: str(identity["orgName"], 120, tally),
797
+ });
798
+ if (kept) file.identity = kept;
799
+ }
800
+
801
+ const flags = flagRecord(input["flags"], 100, tally);
802
+ if (flags) file.flags = flags;
803
+ const extra = flagRecord(input["extra"], 20, tally);
804
+ if (extra) file.extra = extra;
805
+
806
+ const target = bag(input["target"]);
807
+ if (target) {
808
+ const box =
809
+ Array.isArray(target["box"]) &&
810
+ target["box"].length === 4 &&
811
+ target["box"].every((item) => num(item, -1e6, 1e6) !== undefined)
812
+ ? (target["box"].map((item) => Math.round(item as number)) as [
813
+ number,
814
+ number,
815
+ number,
816
+ number,
817
+ ])
818
+ : undefined;
819
+ const stylesIn = bag(target["styles"]);
820
+ const styles: Record<string, string> = {};
821
+ if (stylesIn) {
822
+ for (const [name, item] of Object.entries(stylesIn).slice(0, 20)) {
823
+ if (/^[a-z-]{1,40}$/.test(name) && typeof item === "string")
824
+ styles[name] = scrubText(item, 80, tally);
825
+ }
826
+ }
827
+ const stack = list(target["stack"], 30, (item) => str(item, 80, tally));
828
+ const kept = compact({
829
+ component: str(target["component"], 120, tally),
830
+ file: str(target["file"], 240, tally),
831
+ stack: stack.length ? stack : undefined,
832
+ role: str(target["role"], 40, tally),
833
+ name: str(target["name"], 160, tally),
834
+ tag: str(target["tag"], 20, tally),
835
+ html: str(target["html"], 4096, tally),
836
+ box,
837
+ styles: Object.keys(styles).length ? styles : undefined,
838
+ });
839
+ if (kept) file.target = kept;
840
+ }
841
+
842
+ const errors = list(input["errors"], 30, (item) => readError(item, tally));
843
+ if (errors.length) file.errors = errors;
844
+ const logs = list(input["logs"], 50, (item) => {
845
+ const entry = bag(item);
846
+ const message = entry ? str(entry["message"], 300, tally) : undefined;
847
+ return entry && message
848
+ ? { t: num(entry["t"]) ?? 0, level: str(entry["level"], 10, tally) ?? "log", message }
849
+ : undefined;
850
+ });
851
+ if (logs.length) file.logs = logs;
852
+
853
+ const network = bag(input["network"]);
854
+ if (network) {
855
+ file.network = {
856
+ failed: list(network["failed"], 30, (item) => readRequest(item, tally)),
857
+ slow: list(network["slow"], 20, (item) => readRequest(item, tally)),
858
+ pending: list(network["pending"], 20, (item) => readRequest(item, tally)),
859
+ aborted: Math.round(num(network["aborted"], 0, 1e6) ?? 0),
860
+ };
861
+ }
862
+
863
+ const steps = list(input["steps"], 150, (item) => readStep(item, tally));
864
+ if (steps.length) file.steps = steps;
865
+ const lifecycle = list(input["lifecycle"], 30, (item) => {
866
+ const entry = bag(item);
867
+ const event = entry ? LIFECYCLE.find((name) => name === entry["event"]) : undefined;
868
+ return entry && event ? { t: num(entry["t"]) ?? 0, event } : undefined;
869
+ });
870
+ if (lifecycle.length) file.lifecycle = lifecycle;
871
+
872
+ const performance = bag(input["performance"]);
873
+ if (performance) {
874
+ const kept = compact({
875
+ longTasks: num(performance["longTasks"], 0, 1e6),
876
+ longestTaskMs: num(performance["longestTaskMs"], 0, 1e7),
877
+ inpMs: num(performance["inpMs"], 0, 1e7),
878
+ lcpMs: num(performance["lcpMs"], 0, 1e7),
879
+ cls: num(performance["cls"], 0, 100),
880
+ loadMs: num(performance["loadMs"], 0, 1e7),
881
+ heapMb: num(performance["heapMb"], 0, 1e6),
882
+ });
883
+ if (kept) file.performance = kept;
884
+ }
885
+
886
+ const queries = list(input["queries"], 50, (item) => {
887
+ const entry = bag(item);
888
+ const key = entry ? str(entry["key"], 200, tally) : undefined;
889
+ if (!entry || !key) return undefined;
890
+ return {
891
+ key,
892
+ ...compact({
893
+ status: str(entry["status"], 20, tally),
894
+ fetchStatus: str(entry["fetchStatus"], 20, tally),
895
+ error: str(entry["error"], 300, tally),
896
+ updatedAgoMs: num(entry["updatedAgoMs"], 0, 1e11),
897
+ observers: num(entry["observers"], 0, 1e6),
898
+ }),
899
+ };
900
+ });
901
+ if (queries.length) file.queries = queries;
902
+
903
+ const stores = bag(input["stores"]);
904
+ if (stores) {
905
+ const out: JsonBag = {};
906
+ for (const [name, item] of Object.entries(stores).slice(0, 10)) {
907
+ out[scrubText(name, 60, tally)] = scrubValue(item, {
908
+ depth: 6,
909
+ text: 300,
910
+ entries: 50,
911
+ tally,
912
+ });
913
+ }
914
+ if (Object.keys(out).length) file.stores = out;
915
+ }
916
+
917
+ const storage = bag(input["storage"]);
918
+ if (storage) {
919
+ const cookies = list(storage["cookies"], 50, (item) => str(item, 80, tally));
920
+ const indexedDb = list(storage["indexedDb"], 30, (item) => str(item, 80, tally));
921
+ const kept = compact({
922
+ local: storageList(storage["local"], tally),
923
+ session: storageList(storage["session"], tally),
924
+ cookies: cookies.length ? cookies : undefined,
925
+ indexedDb: indexedDb.length ? indexedDb : undefined,
926
+ });
927
+ if (kept) file.storage = kept;
928
+ }
929
+
930
+ const realtime = bag(input["realtime"]);
931
+ if (realtime) {
932
+ const close = bag(realtime["lastClose"]);
933
+ const kept = compact({
934
+ status: str(realtime["status"], 40, tally),
935
+ lastMessageAgoMs: num(realtime["lastMessageAgoMs"], 0, 1e11),
936
+ reconnects: num(realtime["reconnects"], 0, 1e6),
937
+ lastClose: close
938
+ ? compact({ code: num(close["code"], 0, 9999), reason: str(close["reason"], 120, tally) })
939
+ : undefined,
940
+ });
941
+ if (kept) file.realtime = kept;
942
+ }
943
+
944
+ if (joinIn) {
945
+ const traceId =
946
+ typeof joinIn["traceId"] === "string" ? scrubId(joinIn["traceId"]).slice(0, 120) : "";
947
+ const sessionId =
948
+ typeof joinIn["sessionId"] === "string" ? scrubId(joinIn["sessionId"]).slice(0, 120) : "";
949
+ const requestIds = list(joinIn["requestIds"], 30, (item) =>
950
+ typeof item === "string" ? scrubId(item) || undefined : undefined,
951
+ );
952
+ if (traceId) file.join.traceId = traceId;
953
+ if (sessionId) file.join.sessionId = sessionId;
954
+ if (requestIds.length) file.join.requestIds = requestIds;
955
+ }
956
+
957
+ // Counts from the browser's pass, plus anything this pass found.
958
+ file.redaction.masked = Math.round(num(before?.["masked"], 0, 1e9) ?? 0) + tally.masked;
959
+ file.redaction.dropped = Math.round(num(before?.["dropped"], 0, 1e9) ?? 0) + tally.dropped;
960
+ return file;
961
+ }
962
+
963
+ /** UTF-8 length of `text`, without an encoder (React Native has none). */
964
+ function utf8Length(text: string) {
965
+ let bytes = 0;
966
+ for (let index = 0; index < text.length; index += 1) {
967
+ const code = text.charCodeAt(index);
968
+ if (code < 0x80) bytes += 1;
969
+ else if (code < 0x800) bytes += 2;
970
+ else if (code >= 0xd800 && code <= 0xdbff) {
971
+ bytes += 4;
972
+ index += 1;
973
+ } else bytes += 3;
974
+ }
975
+ return bytes;
976
+ }
977
+
978
+ export function debugContextBytes(file: DebugContextFile) {
979
+ return utf8Length(JSON.stringify(file));
980
+ }
981
+
982
+ /**
983
+ * The file cut down to `maxBytes`, dropping the least useful parts first: store state, storage, the cache,
984
+ * logs, bodies, then the oldest steps, excerpts and stacks. Marks `redaction.truncated` when anything went.
985
+ */
986
+ export function fitDebugContext(
987
+ file: DebugContextFile,
988
+ maxBytes = DEBUG_CONTEXT_MAX_BYTES,
989
+ ): DebugContextFile {
990
+ let current: DebugContextFile = file;
991
+ if (debugContextBytes(current) <= maxBytes) return current;
992
+ const stripBodies = (request: DebugRequest): DebugRequest =>
993
+ without(without(request, "requestBody"), "responseBody");
994
+ const cuts: Array<(next: DebugContextFile) => DebugContextFile> = [
995
+ (next) => without(next, "stores"),
996
+ (next) => without(next, "storage"),
997
+ (next) => (next.queries ? { ...next, queries: next.queries.slice(0, 10) } : next),
998
+ (next) => without(next, "logs"),
999
+ (next) =>
1000
+ next.network
1001
+ ? {
1002
+ ...next,
1003
+ network: {
1004
+ ...next.network,
1005
+ failed: next.network.failed.map(stripBodies),
1006
+ slow: next.network.slow.map(stripBodies),
1007
+ },
1008
+ }
1009
+ : next,
1010
+ (next) => (next.target ? { ...next, target: without(next.target, "html") } : next),
1011
+ (next) => (next.steps ? { ...next, steps: next.steps.slice(-50) } : next),
1012
+ (next) =>
1013
+ next.network
1014
+ ? {
1015
+ ...next,
1016
+ network: {
1017
+ ...next.network,
1018
+ failed: next.network.failed.map((request) =>
1019
+ request.excerpt === undefined
1020
+ ? request
1021
+ : { ...request, excerpt: request.excerpt.slice(0, 300) },
1022
+ ),
1023
+ },
1024
+ }
1025
+ : next,
1026
+ (next) =>
1027
+ next.errors
1028
+ ? {
1029
+ ...next,
1030
+ errors: next.errors
1031
+ .slice(0, 10)
1032
+ .map((error) => (error.stack ? { ...error, stack: error.stack.slice(0, 4) } : error)),
1033
+ }
1034
+ : next,
1035
+ (next) =>
1036
+ without(
1037
+ without(next.steps ? { ...next, steps: next.steps.slice(-15) } : next, "queries"),
1038
+ "flags",
1039
+ ),
1040
+ ];
1041
+ for (const cut of cuts) {
1042
+ current = cut(current);
1043
+ current.redaction = { ...current.redaction, truncated: true };
1044
+ if (debugContextBytes(current) <= maxBytes) return current;
1045
+ }
1046
+ return current;
1047
+ }
1048
+
1049
+ // ---------------------------------------------------------------- the brief
1050
+
1051
+ export type BriefMeta = {
1052
+ title?: string | null;
1053
+ body?: string | null;
1054
+ author?: string | null;
1055
+ kind?: string | null;
1056
+ createdAt?: string | null;
1057
+ threadUrl?: string | null;
1058
+ /** Other files on the thread, by name, so the brief can point at them. */
1059
+ files?: readonly string[];
1060
+ };
1061
+
1062
+ function ago(ms: number) {
1063
+ const value = Math.abs(ms);
1064
+ if (value < 1000) return `${Math.round(value)} ms`;
1065
+ if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
1066
+ if (value < 3_600_000) return `${Math.round(value / 60_000)} min`;
1067
+ return `${(value / 3_600_000).toFixed(1)} h`;
1068
+ }
1069
+
1070
+ function stepLine(step: DebugStep) {
1071
+ switch (step.type) {
1072
+ case "navigate":
1073
+ return `went to ${step.path}`;
1074
+ case "click":
1075
+ return `clicked "${step.label}"`;
1076
+ case "input":
1077
+ return step.redacted
1078
+ ? `typed in "${step.label}"`
1079
+ : `typed "${step.value}" in "${step.label}"`;
1080
+ case "console":
1081
+ return `${step.level === "error" ? "error" : "warning"}: ${step.message}`;
1082
+ case "network":
1083
+ return step.status
1084
+ ? `${step.method} ${step.path} answered ${step.status}`
1085
+ : `${step.method} ${step.path} got no answer`;
1086
+ case "say":
1087
+ return `said "${step.text}"`;
1088
+ default: {
1089
+ const never: never = step;
1090
+ return String(never);
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ function requestLine(request: DebugRequest, what: string) {
1096
+ const ids = request.ids
1097
+ ? Object.entries(request.ids)
1098
+ .map(([name, id]) => `${name} ${id}`)
1099
+ .join(", ")
1100
+ : "";
1101
+ const parts = [
1102
+ `${ago(request.t)} before: ${request.method} ${request.url} ${what}${request.ms !== undefined ? ` in ${request.ms} ms` : ""}`,
1103
+ ];
1104
+ if (request.graphql?.errors.length) {
1105
+ parts.push(
1106
+ ` GraphQL${request.graphql.operation ? ` ${request.graphql.operation}` : ""} errors: ${request.graphql.errors.join("; ")}`,
1107
+ );
1108
+ }
1109
+ if (request.excerpt) parts.push(` ${request.excerpt.replace(/\s+/g, " ").slice(0, 400)}`);
1110
+ if (ids) parts.push(` (${ids})`);
1111
+ return parts.join("\n");
1112
+ }
1113
+
1114
+ function fence(text: string) {
1115
+ return text.replace(/```/g, "ʼʼʼ");
1116
+ }
1117
+
1118
+ /**
1119
+ * The context as a short markdown brief for a coding agent: what was reported, where to look, what broke
1120
+ * (newest first), the steps, the environment, and what else exists. The first three sections are always
1121
+ * there, saying so when they are empty. Kept under about 6,000 tokens.
1122
+ */
1123
+ export function debugContextBrief(file: DebugContextFile, meta: BriefMeta = {}): string {
1124
+ const lines: string[] = [];
1125
+ const on = new Set(file.signals);
1126
+ const kind = meta.kind ? ` (${meta.kind})` : "";
1127
+ const by = meta.author ? ` by ${meta.author}` : "";
1128
+ const at = meta.createdAt ?? file.capturedAt;
1129
+ const route = file.page?.routePattern ?? file.page?.route ?? "an unknown page";
1130
+ lines.push("## Report");
1131
+ const title = meta.title?.trim() || meta.body?.split("\n")[0]?.trim() || "Untitled report";
1132
+ lines.push(
1133
+ `"${fence(title.slice(0, 200))}"${kind}${by}, ${at.replace("T", " ").slice(0, 16)} UTC, on ${route}${file.page?.title ? ` (${file.page.title})` : ""}.`,
1134
+ );
1135
+ if (meta.body && meta.body.trim() !== title)
1136
+ lines.push("", fence(meta.body.trim().slice(0, 1500)));
1137
+ if (file.page?.url) lines.push("", `Page: ${file.page.url}`);
1138
+ if (meta.threadUrl) lines.push(`Thread: ${meta.threadUrl}`);
1139
+
1140
+ lines.push("", "## Where to look");
1141
+ const target = file.target;
1142
+ if (target?.component || target?.file) {
1143
+ const stack = target.stack?.length ? `, stack ${target.stack.join(" > ")}` : "";
1144
+ const what =
1145
+ target.role || target.name
1146
+ ? `, ${target.role ?? "element"}${target.name ? ` "${target.name}"` : ""}`
1147
+ : "";
1148
+ lines.push(
1149
+ `- Target: ${target.component ?? "component"}${target.file ? ` (${target.file})` : ""}${stack}${what}.`,
1150
+ );
1151
+ } else lines.push("- Target: the whole page (no element was pointed at).");
1152
+ const host = file.build?.host;
1153
+ if (host?.commit || host?.version) {
1154
+ const stale = file.build?.staleHost
1155
+ ? ` The tab runs an older build than the server serves (${host.deployed ?? "newer"}).`
1156
+ : "";
1157
+ const loaded =
1158
+ file.page?.loadedAgoMs !== undefined
1159
+ ? ` The page was loaded ${ago(file.page.loadedAgoMs)} before the report.`
1160
+ : "";
1161
+ lines.push(
1162
+ `- Build: ${host.commit ? `commit ${host.commit}` : ""}${host.version ? `${host.commit ? ", " : ""}version ${host.version}` : ""}${host.env ? ` (${host.env})` : ""}.${loaded}${stale}`,
1163
+ );
1164
+ } else
1165
+ lines.push(
1166
+ `- Build: ${on.has("build") ? "the host app did not say which build it runs." : "not captured."}`,
1167
+ );
1168
+
1169
+ lines.push("", "## What broke (newest first)");
1170
+ type Item = { t: number; text: string };
1171
+ const items: Item[] = [];
1172
+ for (const request of file.network?.failed ?? []) {
1173
+ items.push({
1174
+ t: request.t,
1175
+ text: requestLine(request, request.status ? `answered ${request.status}` : "got no answer"),
1176
+ });
1177
+ }
1178
+ for (const error of file.errors ?? []) {
1179
+ const count = error.count > 1 ? `, ${error.count} times` : "";
1180
+ const where = error.stack?.[0] ?? error.source;
1181
+ const component = error.componentStack?.length
1182
+ ? `\n in ${error.componentStack.slice(0, 5).join(" < ")}`
1183
+ : "";
1184
+ items.push({
1185
+ t: error.t,
1186
+ text: `${ago(error.t)} before${count}: ${error.kind === "console" ? `console.${error.level}` : error.kind} "${error.message}"${where ? ` at ${where}` : ""}${component}`,
1187
+ });
1188
+ }
1189
+ items.sort((a, b) => b.t - a.t);
1190
+ if (items.length)
1191
+ items.slice(0, 15).forEach((item, index) => lines.push(`${index + 1}. ${fence(item.text)}`));
1192
+ else if (on.has("errors") || on.has("network"))
1193
+ lines.push("No errors or failed requests were recorded.");
1194
+ else lines.push("Errors and requests were not captured.");
1195
+ const pending = file.network?.pending ?? [];
1196
+ if (pending.length)
1197
+ lines.push(
1198
+ `Still waiting when reported: ${pending
1199
+ .slice(0, 5)
1200
+ .map((request) => `${request.method} ${request.url} (${ago(request.t)})`)
1201
+ .join("; ")}.`,
1202
+ );
1203
+ const slow = file.network?.slow ?? [];
1204
+ if (slow.length)
1205
+ lines.push(
1206
+ `Slow: ${slow
1207
+ .slice(0, 5)
1208
+ .map((request) => `${request.method} ${request.url} ${request.ms ?? "?"} ms`)
1209
+ .join("; ")}.`,
1210
+ );
1211
+ if (file.network?.aborted)
1212
+ lines.push(`${file.network.aborted} request(s) were cancelled by the page.`);
1213
+
1214
+ if (file.steps?.length) {
1215
+ const first = file.steps[0]!.t;
1216
+ lines.push("", `## Steps (last ${ago(first)})`);
1217
+ lines.push(fence(file.steps.slice(-40).map(stepLine).join(", ")) + ".");
1218
+ }
1219
+
1220
+ lines.push("", "## Environment");
1221
+ const client = file.client;
1222
+ if (client) {
1223
+ const parts = [
1224
+ client.browser,
1225
+ client.os ? `on ${client.os}` : undefined,
1226
+ client.device,
1227
+ client.appVersion
1228
+ ? `app ${client.appVersion}${client.appBuild ? ` (${client.appBuild})` : ""}`
1229
+ : undefined,
1230
+ client.viewport
1231
+ ? `${client.viewport[0]}×${client.viewport[1]}${client.dpr ? ` at ${client.dpr}x` : ""}`
1232
+ : undefined,
1233
+ client.locale,
1234
+ client.timezone,
1235
+ client.colorScheme ? `${client.colorScheme} mode` : undefined,
1236
+ client.online === false ? "offline" : client.online ? "online" : undefined,
1237
+ client.connection,
1238
+ ].filter(Boolean);
1239
+ lines.push(`${parts.join(", ")}.`);
1240
+ } else lines.push("Not captured.");
1241
+ const identity = file.identity;
1242
+ if (identity) {
1243
+ const who = [
1244
+ identity.userId ? `User ${identity.userId}` : "A user",
1245
+ identity.role ? `(${identity.role})` : undefined,
1246
+ identity.orgId || identity.orgName
1247
+ ? `in org ${identity.orgName ?? identity.orgId}${identity.orgName && identity.orgId ? ` (${identity.orgId})` : ""}`
1248
+ : undefined,
1249
+ identity.plan ? `on plan ${identity.plan}` : undefined,
1250
+ ].filter(Boolean);
1251
+ lines.push(`${who.join(" ")}.`);
1252
+ }
1253
+ if (file.flags) {
1254
+ const flags = Object.entries(file.flags)
1255
+ .slice(0, 30)
1256
+ .map(
1257
+ ([name, value]) =>
1258
+ `${name}=${value === true ? "on" : value === false ? "off" : String(value)}`,
1259
+ );
1260
+ lines.push(`Flags: ${flags.join(", ")}.`);
1261
+ }
1262
+ if (file.realtime) {
1263
+ const rt = file.realtime;
1264
+ lines.push(
1265
+ `Realtime: ${rt.status ?? "unknown"}${rt.lastMessageAgoMs !== undefined ? `, last message ${ago(rt.lastMessageAgoMs)} ago` : ""}${rt.reconnects ? `, ${rt.reconnects} reconnects` : ""}${rt.lastClose?.code ? `, last close ${rt.lastClose.code}${rt.lastClose.reason ? ` ${rt.lastClose.reason}` : ""}` : ""}.`,
1266
+ );
1267
+ }
1268
+ const offline = file.lifecycle?.filter((event) => event.event === "offline").length ?? 0;
1269
+ if (offline) lines.push(`Went offline ${offline} time(s) before the report.`);
1270
+ if (file.performance) {
1271
+ const p = file.performance;
1272
+ const parts = [
1273
+ p.longTasks !== undefined
1274
+ ? `${p.longTasks} long tasks${p.longestTaskMs ? ` (longest ${Math.round(p.longestTaskMs)} ms)` : ""}`
1275
+ : undefined,
1276
+ p.inpMs !== undefined ? `worst interaction ${Math.round(p.inpMs)} ms` : undefined,
1277
+ p.lcpMs !== undefined ? `LCP ${Math.round(p.lcpMs)} ms` : undefined,
1278
+ p.cls !== undefined ? `CLS ${p.cls}` : undefined,
1279
+ p.heapMb !== undefined ? `heap ${Math.round(p.heapMb)} MB` : undefined,
1280
+ ].filter(Boolean);
1281
+ if (parts.length) lines.push(`Performance: ${parts.join(", ")}.`);
1282
+ }
1283
+ const erroredQueries =
1284
+ file.queries?.filter((query) => query.status === "error" || query.error) ?? [];
1285
+ if (erroredQueries.length)
1286
+ lines.push(
1287
+ `Queries in error: ${erroredQueries
1288
+ .slice(0, 5)
1289
+ .map((query) => `${query.key}${query.error ? ` (${query.error})` : ""}`)
1290
+ .join("; ")}.`,
1291
+ );
1292
+
1293
+ lines.push("", "## More (fetch if needed)");
1294
+ const more = [
1295
+ "full context JSON",
1296
+ file.target?.html ? "target DOM excerpt" : undefined,
1297
+ file.queries?.length ? `${file.queries.length} cached queries` : undefined,
1298
+ file.stores ? `store state (${Object.keys(file.stores).join(", ")})` : undefined,
1299
+ file.storage ? "storage inventory" : undefined,
1300
+ file.logs?.length ? `${file.logs.length} console lines` : undefined,
1301
+ ...(meta.files ?? []).slice(0, 10),
1302
+ ].filter(Boolean);
1303
+ lines.push(`${more.join(", ")}.`);
1304
+ const join = [
1305
+ `report ${file.join.reportId}`,
1306
+ file.join.traceId ? `trace ${file.join.traceId}` : undefined,
1307
+ file.join.sessionId ? `session ${file.join.sessionId}` : undefined,
1308
+ ].filter(Boolean);
1309
+ lines.push(`Join ids: ${join.join(", ")}.`);
1310
+ if (file.redaction.masked || file.redaction.dropped || file.redaction.truncated) {
1311
+ lines.push(
1312
+ `Redaction: ${file.redaction.masked} value(s) masked, ${file.redaction.dropped} dropped${file.redaction.truncated ? ", file trimmed to fit" : ""}. Missing values were removed on purpose.`,
1313
+ );
1314
+ }
1315
+ const text = lines.join("\n");
1316
+ return text.length > BRIEF_MAX_CHARS
1317
+ ? `${text.slice(0, BRIEF_MAX_CHARS - 40)}\n\n(brief cut to fit)`
1318
+ : text;
1319
+ }