react-native-debug-toolkit 3.0.0 → 3.1.2
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 +115 -97
- package/README.zh-CN.md +113 -95
- package/lib/commonjs/core/initialize.js +5 -0
- package/lib/commonjs/core/initialize.js.map +1 -1
- package/lib/commonjs/index.js +23 -26
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/panel/StreamingSettingsModal.js +24 -58
- package/lib/commonjs/ui/panel/StreamingSettingsModal.js.map +1 -1
- package/lib/commonjs/utils/DaemonClient.js +721 -0
- package/lib/commonjs/utils/DaemonClient.js.map +1 -0
- package/lib/commonjs/utils/{sessionReport.js → deviceReport.js} +3 -3
- package/lib/commonjs/utils/deviceReport.js.map +1 -0
- package/lib/module/core/initialize.js +6 -0
- package/lib/module/core/initialize.js.map +1 -1
- package/lib/module/index.js +3 -5
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/panel/StreamingSettingsModal.js +21 -55
- package/lib/module/ui/panel/StreamingSettingsModal.js.map +1 -1
- package/lib/module/utils/DaemonClient.js +703 -0
- package/lib/module/utils/DaemonClient.js.map +1 -0
- package/lib/module/utils/{sessionReport.js → deviceReport.js} +2 -2
- package/lib/module/utils/deviceReport.js.map +1 -0
- package/lib/typescript/src/core/initialize.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -10
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/ui/panel/StreamingSettingsModal.d.ts.map +1 -1
- package/lib/typescript/src/utils/DaemonClient.d.ts +141 -0
- package/lib/typescript/src/utils/DaemonClient.d.ts.map +1 -0
- package/lib/typescript/src/utils/{sessionReport.d.ts → deviceReport.d.ts} +4 -4
- package/lib/typescript/src/utils/deviceReport.d.ts.map +1 -0
- package/node/daemon/src/cli.js +9 -2
- package/node/daemon/src/console/console.html +914 -188
- package/node/daemon/src/constants.js +6 -0
- package/node/daemon/src/server.js +205 -123
- package/node/daemon/src/store.js +122 -45
- package/node/mcp/src/daemonClient.js +6 -6
- package/node/mcp/src/index.js +2 -2
- package/node/mcp/src/logs.js +5 -4
- package/node/mcp/src/tools.js +16 -16
- package/package.json +2 -2
- package/src/core/initialize.ts +8 -0
- package/src/index.ts +18 -10
- package/src/ui/panel/StreamingSettingsModal.tsx +25 -63
- package/src/utils/DaemonClient.ts +887 -0
- package/src/utils/{sessionReport.ts → deviceReport.ts} +6 -6
- package/lib/commonjs/utils/autoDetectDaemon.js +0 -141
- package/lib/commonjs/utils/autoDetectDaemon.js.map +0 -1
- package/lib/commonjs/utils/daemonConnection.js +0 -81
- package/lib/commonjs/utils/daemonConnection.js.map +0 -1
- package/lib/commonjs/utils/daemonSettings.js +0 -110
- package/lib/commonjs/utils/daemonSettings.js.map +0 -1
- package/lib/commonjs/utils/reportToDaemon.js +0 -112
- package/lib/commonjs/utils/reportToDaemon.js.map +0 -1
- package/lib/commonjs/utils/sessionReport.js.map +0 -1
- package/lib/commonjs/utils/streamToDaemon.js +0 -334
- package/lib/commonjs/utils/streamToDaemon.js.map +0 -1
- package/lib/module/utils/autoDetectDaemon.js +0 -136
- package/lib/module/utils/autoDetectDaemon.js.map +0 -1
- package/lib/module/utils/daemonConnection.js +0 -77
- package/lib/module/utils/daemonConnection.js.map +0 -1
- package/lib/module/utils/daemonSettings.js +0 -102
- package/lib/module/utils/daemonSettings.js.map +0 -1
- package/lib/module/utils/reportToDaemon.js +0 -105
- package/lib/module/utils/reportToDaemon.js.map +0 -1
- package/lib/module/utils/sessionReport.js.map +0 -1
- package/lib/module/utils/streamToDaemon.js +0 -328
- package/lib/module/utils/streamToDaemon.js.map +0 -1
- package/lib/typescript/src/utils/autoDetectDaemon.d.ts +0 -15
- package/lib/typescript/src/utils/autoDetectDaemon.d.ts.map +0 -1
- package/lib/typescript/src/utils/daemonConnection.d.ts +0 -18
- package/lib/typescript/src/utils/daemonConnection.d.ts.map +0 -1
- package/lib/typescript/src/utils/daemonSettings.d.ts +0 -19
- package/lib/typescript/src/utils/daemonSettings.d.ts.map +0 -1
- package/lib/typescript/src/utils/reportToDaemon.d.ts +0 -34
- package/lib/typescript/src/utils/reportToDaemon.d.ts.map +0 -1
- package/lib/typescript/src/utils/sessionReport.d.ts.map +0 -1
- package/lib/typescript/src/utils/streamToDaemon.d.ts +0 -23
- package/lib/typescript/src/utils/streamToDaemon.d.ts.map +0 -1
- package/src/utils/autoDetectDaemon.ts +0 -175
- package/src/utils/daemonConnection.ts +0 -133
- package/src/utils/daemonSettings.ts +0 -134
- package/src/utils/reportToDaemon.ts +0 -172
- package/src/utils/streamToDaemon.ts +0 -419
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
import { AppState, type AppStateStatus, Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { DebugToolkit } from '../core/DebugToolkit';
|
|
4
|
+
import {
|
|
5
|
+
createDebugDeviceReport,
|
|
6
|
+
type DebugDeviceReport,
|
|
7
|
+
type DebugDeviceReportOptions,
|
|
8
|
+
} from './deviceReport';
|
|
9
|
+
import { safeStringify } from './safeStringify';
|
|
10
|
+
|
|
11
|
+
// ---- Public Types ----
|
|
12
|
+
|
|
13
|
+
export type DaemonConnectionMode = 'simulator' | 'device';
|
|
14
|
+
|
|
15
|
+
export interface DaemonSettings {
|
|
16
|
+
mode: DaemonConnectionMode;
|
|
17
|
+
endpoint: string;
|
|
18
|
+
deviceHost: string;
|
|
19
|
+
token: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type StreamStatus =
|
|
23
|
+
| { state: 'connecting' }
|
|
24
|
+
| { state: 'connected'; deviceId: string }
|
|
25
|
+
| { state: 'retrying'; retryInMs: number }
|
|
26
|
+
| { state: 'failed'; reason: 'auth' | 'retry_limit' };
|
|
27
|
+
|
|
28
|
+
export interface StreamToDaemonOptions {
|
|
29
|
+
endpoint?: string;
|
|
30
|
+
token?: string;
|
|
31
|
+
debounceMs?: number;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
maxRetryAttempts?: number | null;
|
|
34
|
+
onStatus?: (status: StreamStatus) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DaemonConnectionFailureReason =
|
|
38
|
+
| 'fetch_unavailable'
|
|
39
|
+
| 'timeout'
|
|
40
|
+
| 'http'
|
|
41
|
+
| 'invalid_response'
|
|
42
|
+
| 'network';
|
|
43
|
+
|
|
44
|
+
export type DaemonConnectionResult =
|
|
45
|
+
| { ok: true; endpoint: string; status: number }
|
|
46
|
+
| { ok: false; endpoint: string; reason: DaemonConnectionFailureReason; status?: number; error?: string };
|
|
47
|
+
|
|
48
|
+
export interface DaemonConnectionOptions {
|
|
49
|
+
endpoint?: string;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReportToDaemonOptions extends DebugDeviceReportOptions {
|
|
54
|
+
endpoint?: string;
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
token?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ReportResult {
|
|
60
|
+
ok: boolean;
|
|
61
|
+
endpoint: string;
|
|
62
|
+
report: DebugDeviceReport;
|
|
63
|
+
status?: number;
|
|
64
|
+
deviceId?: string;
|
|
65
|
+
receivedAt?: string;
|
|
66
|
+
logCount?: Record<string, number>;
|
|
67
|
+
error?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- Internal Transport Types ----
|
|
71
|
+
|
|
72
|
+
type FetchResponseLike = {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
status: number;
|
|
75
|
+
json?: () => Promise<unknown>;
|
|
76
|
+
text?: () => Promise<string>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type FetchLike = (url: string, init: {
|
|
80
|
+
method: string;
|
|
81
|
+
headers: Record<string, string>;
|
|
82
|
+
body?: string;
|
|
83
|
+
signal?: unknown;
|
|
84
|
+
}) => Promise<FetchResponseLike>;
|
|
85
|
+
|
|
86
|
+
type AbortControllerLike = { signal: unknown; abort: () => void };
|
|
87
|
+
type AbortControllerCtor = new () => AbortControllerLike;
|
|
88
|
+
|
|
89
|
+
type SendResult = 'ok' | 'retry' | 'auth_failed';
|
|
90
|
+
|
|
91
|
+
// ---- Constants ----
|
|
92
|
+
|
|
93
|
+
const DEFAULT_HEALTH_TIMEOUT_MS = 2000;
|
|
94
|
+
const DEFAULT_DEBOUNCE_MS = 200;
|
|
95
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
96
|
+
const DEFAULT_REPORT_TIMEOUT_MS = 3000;
|
|
97
|
+
const RETRY_BASE_MS = 1000;
|
|
98
|
+
const MAX_RETRY_DELAY_MS = 30000;
|
|
99
|
+
const BACKGROUND_RESYNC_THRESHOLD_MS = 5 * 60 * 1000;
|
|
100
|
+
|
|
101
|
+
// ---- Standalone Utilities (also used internally) ----
|
|
102
|
+
|
|
103
|
+
export function getDefaultDaemonEndpoint(): string {
|
|
104
|
+
if (Platform.OS === 'android') {
|
|
105
|
+
return 'http://10.0.2.2:3799';
|
|
106
|
+
}
|
|
107
|
+
return 'http://localhost:3799';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildDeviceDaemonEndpoint(host: string): string {
|
|
111
|
+
const trimmed = host.trim().replace(/\/+$/, '');
|
|
112
|
+
if (!trimmed) return '';
|
|
113
|
+
const withProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)
|
|
114
|
+
? trimmed
|
|
115
|
+
: `http://${trimmed}`;
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(withProtocol);
|
|
118
|
+
if (!url.port) url.port = '3799';
|
|
119
|
+
return url.toString().replace(/\/$/, '');
|
|
120
|
+
} catch {
|
|
121
|
+
return withProtocol;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function normalizeDaemonSettings(settings: DaemonSettings): {
|
|
126
|
+
endpoint?: string;
|
|
127
|
+
token?: string;
|
|
128
|
+
} {
|
|
129
|
+
const endpoint = settings.mode === 'device'
|
|
130
|
+
? buildDeviceDaemonEndpoint(settings.deviceHost)
|
|
131
|
+
: '';
|
|
132
|
+
const token = settings.token.trim();
|
|
133
|
+
return {
|
|
134
|
+
endpoint: endpoint || undefined,
|
|
135
|
+
token: token || undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- Stream State (internal) ----
|
|
140
|
+
|
|
141
|
+
interface StreamState {
|
|
142
|
+
endpoint: string;
|
|
143
|
+
reportUrl: string;
|
|
144
|
+
ingestUrl: string;
|
|
145
|
+
token: string | undefined;
|
|
146
|
+
debounceMs: number;
|
|
147
|
+
timeoutMs: number;
|
|
148
|
+
deviceId: string | null;
|
|
149
|
+
sending: boolean;
|
|
150
|
+
debounceTimer: ReturnType<typeof setTimeout> | null;
|
|
151
|
+
retryTimer: ReturnType<typeof setTimeout> | null;
|
|
152
|
+
retryAttempt: number;
|
|
153
|
+
maxRetryAttempts: number | null;
|
|
154
|
+
dirtyFeatures: Set<string>;
|
|
155
|
+
lastSentIds: Map<string, Set<string | number>>;
|
|
156
|
+
featureUnsubscribes: Array<() => void>;
|
|
157
|
+
appStateUnsubscribe: (() => void) | null;
|
|
158
|
+
backgroundedAt: number | null;
|
|
159
|
+
onStatus: ((status: StreamStatus) => void) | undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- DaemonClient ----
|
|
163
|
+
|
|
164
|
+
export interface DaemonClientOptions {
|
|
165
|
+
fetch?: FetchLike;
|
|
166
|
+
AbortController?: AbortControllerCtor;
|
|
167
|
+
onEndpointDetected?: (url: string) => void;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export class DaemonClient {
|
|
171
|
+
private _settings: DaemonSettings = {
|
|
172
|
+
mode: 'simulator',
|
|
173
|
+
endpoint: '',
|
|
174
|
+
deviceHost: '',
|
|
175
|
+
token: '',
|
|
176
|
+
};
|
|
177
|
+
private _streamingEnabled: boolean | null = null;
|
|
178
|
+
private _stream: StreamState | null = null;
|
|
179
|
+
private _fetch: FetchLike | undefined;
|
|
180
|
+
private _AbortController: AbortControllerCtor | undefined;
|
|
181
|
+
private _onEndpointDetected: ((url: string) => void) | undefined;
|
|
182
|
+
private _restorePromise: Promise<void> | null = null;
|
|
183
|
+
|
|
184
|
+
constructor(options?: DaemonClientOptions) {
|
|
185
|
+
this._fetch = options?.fetch;
|
|
186
|
+
this._AbortController = options?.AbortController;
|
|
187
|
+
this._onEndpointDetected = options?.onEndpointDetected;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- Settings ---
|
|
191
|
+
|
|
192
|
+
getSettings(): DaemonSettings {
|
|
193
|
+
return { ...this._settings };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
configure(settings: DaemonSettings): void {
|
|
197
|
+
const normalized = normalizeDaemonSettings(settings);
|
|
198
|
+
this._settings = {
|
|
199
|
+
mode: settings.mode,
|
|
200
|
+
deviceHost: settings.deviceHost.trim(),
|
|
201
|
+
endpoint: normalized.endpoint || '',
|
|
202
|
+
token: settings.token.trim(),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Connection Health Check ---
|
|
207
|
+
|
|
208
|
+
async checkConnection(
|
|
209
|
+
options: DaemonConnectionOptions = {},
|
|
210
|
+
): Promise<DaemonConnectionResult> {
|
|
211
|
+
const endpoint = options.endpoint ?? getDefaultDaemonEndpoint();
|
|
212
|
+
const healthUrl = buildDaemonUrl(endpoint, '/health');
|
|
213
|
+
const fetchImpl = this.resolveFetch();
|
|
214
|
+
|
|
215
|
+
if (!fetchImpl) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
endpoint,
|
|
219
|
+
reason: 'fetch_unavailable',
|
|
220
|
+
error: 'global fetch is not available',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS);
|
|
225
|
+
const controller = this.createAbortController();
|
|
226
|
+
let timedOut = false;
|
|
227
|
+
const timeout = controller && timeoutMs > 0
|
|
228
|
+
? setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs)
|
|
229
|
+
: undefined;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const response = await fetchImpl(healthUrl, {
|
|
233
|
+
method: 'GET',
|
|
234
|
+
headers: {},
|
|
235
|
+
signal: controller?.signal,
|
|
236
|
+
});
|
|
237
|
+
const body = await readHealthBody(response);
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
return { ok: false, endpoint, reason: 'http', status: response.status };
|
|
241
|
+
}
|
|
242
|
+
if (body?.ok !== true) {
|
|
243
|
+
return { ok: false, endpoint, reason: 'invalid_response', status: response.status };
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, endpoint, status: response.status };
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
endpoint,
|
|
250
|
+
reason: timedOut ? 'timeout' : 'network',
|
|
251
|
+
error: error instanceof Error ? error.message : String(error),
|
|
252
|
+
};
|
|
253
|
+
} finally {
|
|
254
|
+
if (timeout) clearTimeout(timeout);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- Streaming ---
|
|
259
|
+
|
|
260
|
+
connect(options: StreamToDaemonOptions = {}): void {
|
|
261
|
+
if (this._stream) return;
|
|
262
|
+
|
|
263
|
+
const endpoint = options.endpoint || this.resolveEndpoint();
|
|
264
|
+
const reportUrl = buildDaemonUrl(endpoint, '/report');
|
|
265
|
+
const ingestUrl = buildDaemonUrl(endpoint, '/ingest');
|
|
266
|
+
|
|
267
|
+
this.notifyEndpoint(endpoint);
|
|
268
|
+
this.notifyEndpoint(reportUrl);
|
|
269
|
+
this.notifyEndpoint(ingestUrl);
|
|
270
|
+
|
|
271
|
+
const state: StreamState = {
|
|
272
|
+
endpoint,
|
|
273
|
+
reportUrl,
|
|
274
|
+
ingestUrl,
|
|
275
|
+
token: options.token ?? (this._settings.token.trim() || undefined),
|
|
276
|
+
debounceMs: options.debounceMs || DEFAULT_DEBOUNCE_MS,
|
|
277
|
+
timeoutMs: Math.max(0, options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
278
|
+
deviceId: null,
|
|
279
|
+
sending: false,
|
|
280
|
+
debounceTimer: null,
|
|
281
|
+
retryTimer: null,
|
|
282
|
+
retryAttempt: 0,
|
|
283
|
+
maxRetryAttempts: typeof options.maxRetryAttempts === 'number'
|
|
284
|
+
? Math.max(0, Math.floor(options.maxRetryAttempts))
|
|
285
|
+
: null,
|
|
286
|
+
dirtyFeatures: new Set(),
|
|
287
|
+
lastSentIds: new Map(),
|
|
288
|
+
featureUnsubscribes: [],
|
|
289
|
+
appStateUnsubscribe: null,
|
|
290
|
+
backgroundedAt: null,
|
|
291
|
+
onStatus: options.onStatus,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
for (const feature of DebugToolkit.features) {
|
|
295
|
+
if (!feature.subscribe) continue;
|
|
296
|
+
const unsub = feature.subscribe(() => { this.onFeatureChange(feature.name); });
|
|
297
|
+
state.featureUnsubscribes.push(unsub);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
state.appStateUnsubscribe = AppState.addEventListener(
|
|
301
|
+
'change',
|
|
302
|
+
(nextState) => { this.handleAppStateChange(nextState); },
|
|
303
|
+
).remove;
|
|
304
|
+
|
|
305
|
+
this._stream = state;
|
|
306
|
+
this.emitStatus({ state: 'connecting' });
|
|
307
|
+
this.enqueueSendFullReport();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
disconnect(): void {
|
|
311
|
+
if (!this._stream) return;
|
|
312
|
+
const state = this._stream;
|
|
313
|
+
this._stream = null;
|
|
314
|
+
|
|
315
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
316
|
+
if (state.retryTimer) clearTimeout(state.retryTimer);
|
|
317
|
+
state.featureUnsubscribes.forEach((fn) => fn());
|
|
318
|
+
state.appStateUnsubscribe?.();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
isConnected(): boolean {
|
|
322
|
+
return this._stream !== null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
getStatus(): StreamStatus | null {
|
|
326
|
+
if (!this._stream) return null;
|
|
327
|
+
if (this._stream.deviceId) {
|
|
328
|
+
return { state: 'connected', deviceId: this._stream.deviceId };
|
|
329
|
+
}
|
|
330
|
+
if (this._stream.retryTimer) {
|
|
331
|
+
return {
|
|
332
|
+
state: 'retrying',
|
|
333
|
+
retryInMs: Math.min(
|
|
334
|
+
RETRY_BASE_MS * (2 ** this._stream.retryAttempt),
|
|
335
|
+
MAX_RETRY_DELAY_MS,
|
|
336
|
+
),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { state: 'connecting' };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setStreamingEnabled(enabled: boolean): void {
|
|
343
|
+
this._streamingEnabled = enabled;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
setEndpointDetector(callback: (url: string) => void): void {
|
|
347
|
+
this._onEndpointDetected = callback;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- Restore (init-time reconnect) ---
|
|
351
|
+
|
|
352
|
+
async restore(): Promise<void> {
|
|
353
|
+
if (this._restorePromise) return this._restorePromise;
|
|
354
|
+
this._restorePromise = this.doRestore().finally(() => {
|
|
355
|
+
this._restorePromise = null;
|
|
356
|
+
});
|
|
357
|
+
return this._restorePromise;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async doRestore(): Promise<void> {
|
|
361
|
+
if (this.isConnected()) return;
|
|
362
|
+
|
|
363
|
+
const enabled = this._streamingEnabled;
|
|
364
|
+
const options = normalizeDaemonSettings(this._settings);
|
|
365
|
+
|
|
366
|
+
if (enabled === false) return;
|
|
367
|
+
if (enabled === true) {
|
|
368
|
+
this.connect();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const canProbe = this._settings.mode === 'simulator'
|
|
373
|
+
|| Boolean(this._settings.deviceHost.trim());
|
|
374
|
+
if (!canProbe) return;
|
|
375
|
+
|
|
376
|
+
const endpoint = options.endpoint || this.resolveEndpoint();
|
|
377
|
+
const connection = await this.checkConnection({
|
|
378
|
+
...options,
|
|
379
|
+
endpoint,
|
|
380
|
+
timeoutMs: 1000,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (!connection.ok || this.isConnected()) return;
|
|
384
|
+
|
|
385
|
+
this._streamingEnabled = true;
|
|
386
|
+
this.connect();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- One-shot Report ---
|
|
390
|
+
|
|
391
|
+
async reportOnce(options: ReportToDaemonOptions = {}): Promise<ReportResult> {
|
|
392
|
+
const endpoint = options.endpoint ?? this.resolveEndpoint();
|
|
393
|
+
const reportUrl = buildDaemonUrl(endpoint, '/report');
|
|
394
|
+
const report = createDebugDeviceReport(options);
|
|
395
|
+
const fetchImpl = this.resolveFetch();
|
|
396
|
+
|
|
397
|
+
this.notifyEndpoint(endpoint);
|
|
398
|
+
this.notifyEndpoint(reportUrl);
|
|
399
|
+
|
|
400
|
+
if (!fetchImpl) {
|
|
401
|
+
return { ok: false, endpoint, report, error: 'global fetch is not available' };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_REPORT_TIMEOUT_MS);
|
|
405
|
+
const controller = this.createAbortController();
|
|
406
|
+
const timeout = controller && timeoutMs > 0
|
|
407
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
408
|
+
: undefined;
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
412
|
+
if (options.token) headers.Authorization = `Bearer ${options.token}`;
|
|
413
|
+
|
|
414
|
+
const response = await fetchImpl(reportUrl, {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers,
|
|
417
|
+
body: JSON.stringify(report),
|
|
418
|
+
signal: controller?.signal,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const bodyObject = await readReportResponseBody(response);
|
|
422
|
+
const logCount = readLogCount(bodyObject.logCount);
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
ok: response.ok && bodyObject.ok === true,
|
|
426
|
+
endpoint,
|
|
427
|
+
report,
|
|
428
|
+
status: response.status,
|
|
429
|
+
deviceId: typeof bodyObject.deviceId === 'string' ? bodyObject.deviceId : undefined,
|
|
430
|
+
receivedAt: typeof bodyObject.receivedAt === 'string' ? bodyObject.receivedAt : undefined,
|
|
431
|
+
logCount,
|
|
432
|
+
error: response.ok
|
|
433
|
+
? undefined
|
|
434
|
+
: typeof bodyObject.error === 'string'
|
|
435
|
+
? bodyObject.error
|
|
436
|
+
: 'Report failed',
|
|
437
|
+
};
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
endpoint,
|
|
442
|
+
report,
|
|
443
|
+
error: error instanceof Error ? error.message : String(error),
|
|
444
|
+
};
|
|
445
|
+
} finally {
|
|
446
|
+
if (timeout) clearTimeout(timeout);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// --- Test Helpers ---
|
|
451
|
+
|
|
452
|
+
_resetForTesting(): void {
|
|
453
|
+
this.disconnect();
|
|
454
|
+
this._settings = { mode: 'simulator', endpoint: '', deviceHost: '', token: '' };
|
|
455
|
+
this._streamingEnabled = null;
|
|
456
|
+
this._restorePromise = null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---- Private: Transport ----
|
|
460
|
+
|
|
461
|
+
private resolveEndpoint(): string {
|
|
462
|
+
const normalized = normalizeDaemonSettings(this._settings);
|
|
463
|
+
return normalized.endpoint || getDefaultDaemonEndpoint();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private resolveFetch(): FetchLike | undefined {
|
|
467
|
+
return this._fetch ?? (globalThis as { fetch?: FetchLike }).fetch;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private createAbortController(): AbortControllerLike | undefined {
|
|
471
|
+
if (this._AbortController) return new this._AbortController();
|
|
472
|
+
const Ctor = (globalThis as { AbortController?: AbortControllerCtor }).AbortController;
|
|
473
|
+
return Ctor ? new Ctor() : undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private notifyEndpoint(url: string): void {
|
|
477
|
+
this._onEndpointDetected?.(url);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private emitStatus(status: StreamStatus): void {
|
|
481
|
+
try {
|
|
482
|
+
this._stream?.onStatus?.(status);
|
|
483
|
+
} catch {
|
|
484
|
+
// Consumer callbacks must not affect delivery.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private async doPost(
|
|
489
|
+
url: string,
|
|
490
|
+
headers: Record<string, string>,
|
|
491
|
+
body: unknown,
|
|
492
|
+
timeoutMs: number,
|
|
493
|
+
): Promise<{ status: number; json?: () => Promise<unknown> } | null> {
|
|
494
|
+
const fetchImpl = this.resolveFetch();
|
|
495
|
+
if (!fetchImpl) return null;
|
|
496
|
+
const controller = this.createAbortController();
|
|
497
|
+
const timeout = controller && timeoutMs > 0
|
|
498
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
499
|
+
: undefined;
|
|
500
|
+
try {
|
|
501
|
+
return await fetchImpl(url, {
|
|
502
|
+
method: 'POST',
|
|
503
|
+
headers,
|
|
504
|
+
body: safeStringify(body),
|
|
505
|
+
signal: controller?.signal,
|
|
506
|
+
});
|
|
507
|
+
} catch {
|
|
508
|
+
return null;
|
|
509
|
+
} finally {
|
|
510
|
+
if (timeout) clearTimeout(timeout);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---- Private: Streaming State Machine ----
|
|
515
|
+
|
|
516
|
+
private emitStreamStatus(state: StreamState, status: StreamStatus): void {
|
|
517
|
+
try {
|
|
518
|
+
state.onStatus?.(status);
|
|
519
|
+
} catch {
|
|
520
|
+
// Consumer callbacks must not affect delivery.
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private resetRetry(state: StreamState): void {
|
|
525
|
+
state.retryAttempt = 0;
|
|
526
|
+
if (state.retryTimer) {
|
|
527
|
+
clearTimeout(state.retryTimer);
|
|
528
|
+
state.retryTimer = null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private failStreaming(state: StreamState, reason: 'auth' | 'retry_limit'): void {
|
|
533
|
+
if (this._stream !== state) return;
|
|
534
|
+
this.emitStreamStatus(state, { state: 'failed', reason });
|
|
535
|
+
this.disconnect();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private scheduleRetry(state: StreamState): void {
|
|
539
|
+
if (state.retryTimer) return;
|
|
540
|
+
if (state.maxRetryAttempts !== null && state.retryAttempt >= state.maxRetryAttempts) {
|
|
541
|
+
this.failStreaming(state, 'retry_limit');
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const delay = Math.min(RETRY_BASE_MS * (2 ** state.retryAttempt), MAX_RETRY_DELAY_MS);
|
|
545
|
+
state.retryAttempt += 1;
|
|
546
|
+
this.emitStreamStatus(state, { state: 'retrying', retryInMs: delay });
|
|
547
|
+
state.retryTimer = setTimeout(() => {
|
|
548
|
+
state.retryTimer = null;
|
|
549
|
+
if (this._stream !== state) return;
|
|
550
|
+
if (state.deviceId) {
|
|
551
|
+
this.enqueueSendDelta();
|
|
552
|
+
} else {
|
|
553
|
+
this.enqueueSendFullReport();
|
|
554
|
+
}
|
|
555
|
+
}, delay);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private scheduleDelta(state: StreamState): void {
|
|
559
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
560
|
+
state.debounceTimer = setTimeout(() => {
|
|
561
|
+
state.debounceTimer = null;
|
|
562
|
+
if (this._stream === state) this.enqueueSendDelta();
|
|
563
|
+
}, state.debounceMs);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private onFeatureChange(featureName: string): void {
|
|
567
|
+
if (!this._stream) return;
|
|
568
|
+
this._stream.dirtyFeatures.add(featureName);
|
|
569
|
+
if (this._stream.retryTimer) return;
|
|
570
|
+
this.scheduleDelta(this._stream);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private handleAppStateChange(nextState: AppStateStatus): void {
|
|
574
|
+
if (!this._stream) return;
|
|
575
|
+
const state = this._stream;
|
|
576
|
+
|
|
577
|
+
if (nextState === 'background') {
|
|
578
|
+
state.backgroundedAt = Date.now();
|
|
579
|
+
if (state.debounceTimer) {
|
|
580
|
+
clearTimeout(state.debounceTimer);
|
|
581
|
+
state.debounceTimer = null;
|
|
582
|
+
}
|
|
583
|
+
this.enqueueSendDelta();
|
|
584
|
+
} else if (nextState === 'active') {
|
|
585
|
+
const wasAway = state.backgroundedAt ? Date.now() - state.backgroundedAt : 0;
|
|
586
|
+
state.backgroundedAt = null;
|
|
587
|
+
if (wasAway > BACKGROUND_RESYNC_THRESHOLD_MS || !state.deviceId) {
|
|
588
|
+
state.deviceId = null;
|
|
589
|
+
state.lastSentIds.clear();
|
|
590
|
+
this.enqueueSendFullReport();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private fetchHeaders(state: StreamState): Record<string, string> {
|
|
596
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
597
|
+
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
|
598
|
+
return headers;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---- Private: Full Report ----
|
|
602
|
+
|
|
603
|
+
private enqueueSendFullReport(): void {
|
|
604
|
+
const state = this._stream;
|
|
605
|
+
if (!state || state.sending) return;
|
|
606
|
+
state.sending = true;
|
|
607
|
+
|
|
608
|
+
(async () => {
|
|
609
|
+
let result: SendResult = 'ok';
|
|
610
|
+
try {
|
|
611
|
+
result = await this.doSendFullReport(state);
|
|
612
|
+
if (result === 'ok') this.resetRetry(state);
|
|
613
|
+
} finally {
|
|
614
|
+
state.sending = false;
|
|
615
|
+
if (this._stream !== state) return;
|
|
616
|
+
if (result === 'auth_failed') {
|
|
617
|
+
this.failStreaming(state, 'auth');
|
|
618
|
+
} else if (result === 'retry') {
|
|
619
|
+
this.scheduleRetry(state);
|
|
620
|
+
} else if (state.dirtyFeatures.size > 0 && !state.debounceTimer) {
|
|
621
|
+
this.scheduleDelta(state);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
})();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private async doSendFullReport(state: StreamState): Promise<SendResult> {
|
|
628
|
+
const report = createDebugDeviceReport();
|
|
629
|
+
const response = await this.doPost(
|
|
630
|
+
state.reportUrl,
|
|
631
|
+
this.fetchHeaders(state),
|
|
632
|
+
report,
|
|
633
|
+
state.timeoutMs,
|
|
634
|
+
);
|
|
635
|
+
if (!response) return 'retry';
|
|
636
|
+
if (isAuthFailure(response.status)) return 'auth_failed';
|
|
637
|
+
if (response.status < 200 || response.status >= 300) return 'retry';
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
const body = response.json
|
|
641
|
+
? (await response.json()) as Record<string, unknown> | null
|
|
642
|
+
: null;
|
|
643
|
+
if (body?.ok !== true || typeof body.deviceId !== 'string') return 'retry';
|
|
644
|
+
state.deviceId = body.deviceId;
|
|
645
|
+
this.emitStreamStatus(state, { state: 'connected', deviceId: body.deviceId });
|
|
646
|
+
} catch {
|
|
647
|
+
return 'retry';
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
state.lastSentIds.clear();
|
|
651
|
+
for (const feature of DebugToolkit.features) {
|
|
652
|
+
try {
|
|
653
|
+
const snapshot = feature.getSnapshot();
|
|
654
|
+
if (Array.isArray(snapshot)) {
|
|
655
|
+
state.lastSentIds.set(feature.name, snapshotToIds(snapshot));
|
|
656
|
+
}
|
|
657
|
+
} catch {
|
|
658
|
+
// skip
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return 'ok';
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---- Private: Delta ----
|
|
665
|
+
|
|
666
|
+
private enqueueSendDelta(): void {
|
|
667
|
+
const state = this._stream;
|
|
668
|
+
if (!state || state.sending || state.dirtyFeatures.size === 0) return;
|
|
669
|
+
state.sending = true;
|
|
670
|
+
|
|
671
|
+
(async () => {
|
|
672
|
+
let retry = false;
|
|
673
|
+
try {
|
|
674
|
+
const delta: Record<string, unknown[]> = {};
|
|
675
|
+
const nextSentIds = new Map<string, Set<string | number>>();
|
|
676
|
+
const features = DebugToolkit.features;
|
|
677
|
+
|
|
678
|
+
for (const featureName of state.dirtyFeatures) {
|
|
679
|
+
const feature = features.find((f) => f.name === featureName);
|
|
680
|
+
if (!feature) continue;
|
|
681
|
+
let snapshot: unknown;
|
|
682
|
+
try { snapshot = feature.getSnapshot(); } catch { continue; }
|
|
683
|
+
if (!Array.isArray(snapshot)) continue;
|
|
684
|
+
|
|
685
|
+
const prevIds = state.lastSentIds.get(featureName) || new Set<string | number>();
|
|
686
|
+
const newEntries = snapshot.filter((entry) => {
|
|
687
|
+
const id = getEntryId(entry);
|
|
688
|
+
return id != null && !prevIds.has(id);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
if (newEntries.length > 0) {
|
|
692
|
+
delta[featureName] = newEntries;
|
|
693
|
+
nextSentIds.set(featureName, snapshotToIds(snapshot));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
state.dirtyFeatures.clear();
|
|
698
|
+
state.debounceTimer = null;
|
|
699
|
+
|
|
700
|
+
if (Object.keys(delta).length === 0) return;
|
|
701
|
+
|
|
702
|
+
if (!state.deviceId) {
|
|
703
|
+
const result = await this.doSendFullReport(state);
|
|
704
|
+
retry = result === 'retry';
|
|
705
|
+
if (result !== 'ok') {
|
|
706
|
+
Object.keys(delta).forEach((n) => state.dirtyFeatures.add(n));
|
|
707
|
+
}
|
|
708
|
+
if (result === 'auth_failed') this.failStreaming(state, 'auth');
|
|
709
|
+
if (result === 'ok') this.resetRetry(state);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const response = await this.doPost(
|
|
714
|
+
state.ingestUrl,
|
|
715
|
+
this.fetchHeaders(state),
|
|
716
|
+
{ deviceId: state.deviceId, delta: { logs: delta } },
|
|
717
|
+
state.timeoutMs,
|
|
718
|
+
);
|
|
719
|
+
if (!response) {
|
|
720
|
+
Object.keys(delta).forEach((n) => state.dirtyFeatures.add(n));
|
|
721
|
+
retry = true;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (response.status === 404) {
|
|
726
|
+
state.deviceId = null;
|
|
727
|
+
state.lastSentIds.clear();
|
|
728
|
+
const result = await this.doSendFullReport(state);
|
|
729
|
+
retry = result === 'retry';
|
|
730
|
+
if (result !== 'ok') {
|
|
731
|
+
Object.keys(delta).forEach((n) => state.dirtyFeatures.add(n));
|
|
732
|
+
}
|
|
733
|
+
if (result === 'auth_failed') this.failStreaming(state, 'auth');
|
|
734
|
+
if (result === 'ok') this.resetRetry(state);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (isAuthFailure(response.status)) {
|
|
739
|
+
Object.keys(delta).forEach((n) => state.dirtyFeatures.add(n));
|
|
740
|
+
this.failStreaming(state, 'auth');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (response.status < 200 || response.status >= 300) {
|
|
745
|
+
Object.keys(delta).forEach((n) => state.dirtyFeatures.add(n));
|
|
746
|
+
retry = true;
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
nextSentIds.forEach((ids, featureName) => {
|
|
751
|
+
state.lastSentIds.set(featureName, ids);
|
|
752
|
+
});
|
|
753
|
+
this.resetRetry(state);
|
|
754
|
+
if (state.deviceId) {
|
|
755
|
+
this.emitStreamStatus(state, { state: 'connected', deviceId: state.deviceId });
|
|
756
|
+
}
|
|
757
|
+
} finally {
|
|
758
|
+
state.sending = false;
|
|
759
|
+
if (this._stream !== state) return;
|
|
760
|
+
if (retry && state.dirtyFeatures.size > 0) {
|
|
761
|
+
this.scheduleRetry(state);
|
|
762
|
+
} else if (state.dirtyFeatures.size > 0 && !state.debounceTimer) {
|
|
763
|
+
this.scheduleDelta(state);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
})();
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---- Module-level Singleton ----
|
|
771
|
+
|
|
772
|
+
export const daemonClient = new DaemonClient();
|
|
773
|
+
|
|
774
|
+
// ---- Backward-compatible Function Exports ----
|
|
775
|
+
|
|
776
|
+
export async function loadDaemonSettings(): Promise<DaemonSettings> {
|
|
777
|
+
return daemonClient.getSettings();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export async function saveDaemonSettings(settings: DaemonSettings): Promise<void> {
|
|
781
|
+
daemonClient.configure(settings);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export async function loadDaemonStreamingEnabled(): Promise<boolean | null> {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export async function saveDaemonStreamingEnabled(enabled: boolean): Promise<void> {
|
|
789
|
+
daemonClient.setStreamingEnabled(enabled);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function startStreaming(options: StreamToDaemonOptions = {}): void {
|
|
793
|
+
daemonClient.connect(options);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function stopStreaming(): void {
|
|
797
|
+
daemonClient.disconnect();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export function isStreaming(): boolean {
|
|
801
|
+
return daemonClient.isConnected();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export function checkDaemonConnection(
|
|
805
|
+
options: DaemonConnectionOptions = {},
|
|
806
|
+
): Promise<DaemonConnectionResult> {
|
|
807
|
+
return daemonClient.checkConnection(options);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export function reportDebugDeviceToDaemon(
|
|
811
|
+
options: ReportToDaemonOptions = {},
|
|
812
|
+
): Promise<ReportResult> {
|
|
813
|
+
return daemonClient.reportOnce(options);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export function restoreDaemonStreaming(): Promise<void> {
|
|
817
|
+
return daemonClient.restore();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ---- Internal Helpers ----
|
|
821
|
+
|
|
822
|
+
function buildDaemonUrl(endpoint: string, path: string): string {
|
|
823
|
+
const trimmed = endpoint.replace(/\/+$/, '');
|
|
824
|
+
return trimmed.endsWith(path) ? trimmed : `${trimmed}${path}`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function isAuthFailure(status: number): boolean {
|
|
828
|
+
return status === 401 || status === 403;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function getEntryId(entry: unknown): string | number | null {
|
|
832
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
833
|
+
const id = (entry as Record<string, unknown>).id;
|
|
834
|
+
return typeof id === 'string' || typeof id === 'number' ? id : null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function snapshotToIds(snapshot: unknown[]): Set<string | number> {
|
|
838
|
+
return new Set(
|
|
839
|
+
snapshot
|
|
840
|
+
.map(getEntryId)
|
|
841
|
+
.filter((id): id is string | number => id != null),
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function readHealthBody(
|
|
846
|
+
response: FetchResponseLike,
|
|
847
|
+
): Promise<Record<string, unknown> | null> {
|
|
848
|
+
try {
|
|
849
|
+
const body = response.json ? await response.json() : null;
|
|
850
|
+
return body && typeof body === 'object' && !Array.isArray(body)
|
|
851
|
+
? body as Record<string, unknown>
|
|
852
|
+
: null;
|
|
853
|
+
} catch {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function readReportResponseBody(
|
|
859
|
+
response: FetchResponseLike,
|
|
860
|
+
): Promise<Record<string, unknown>> {
|
|
861
|
+
try {
|
|
862
|
+
if (response.json) {
|
|
863
|
+
const raw = await response.json();
|
|
864
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
865
|
+
return raw as Record<string, unknown>;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
} catch {
|
|
869
|
+
// ignore
|
|
870
|
+
}
|
|
871
|
+
return {};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function readLogCount(value: unknown): Record<string, number> | undefined {
|
|
875
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
876
|
+
return Object.entries(value as Record<string, unknown>).reduce<Record<string, number>>(
|
|
877
|
+
(acc, [key, count]) => {
|
|
878
|
+
if (typeof count === 'number') acc[key] = count;
|
|
879
|
+
return acc;
|
|
880
|
+
},
|
|
881
|
+
{},
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function _resetDaemonClientForTesting(): void {
|
|
886
|
+
daemonClient._resetForTesting();
|
|
887
|
+
}
|