react-native-debug-toolkit 2.3.0 → 3.0.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.
- package/bin/debug-toolkit.js +114 -0
- package/lib/commonjs/features/network/index.js +28 -2
- package/lib/commonjs/features/network/index.js.map +1 -1
- package/lib/commonjs/features/network/networkInterceptor.js +14 -6
- package/lib/commonjs/features/network/networkInterceptor.js.map +1 -1
- package/lib/commonjs/index.js +59 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/panel/DebugPanel.js +25 -0
- package/lib/commonjs/ui/panel/DebugPanel.js.map +1 -1
- package/lib/commonjs/ui/panel/FloatPanelView.js +15 -62
- package/lib/commonjs/ui/panel/FloatPanelView.js.map +1 -1
- package/lib/commonjs/ui/panel/StreamingSettingsModal.js +529 -0
- package/lib/commonjs/ui/panel/StreamingSettingsModal.js.map +1 -0
- package/lib/commonjs/ui/panel/useTabAnimation.js +71 -0
- package/lib/commonjs/ui/panel/useTabAnimation.js.map +1 -0
- package/lib/commonjs/utils/autoDetectDaemon.js +141 -0
- package/lib/commonjs/utils/autoDetectDaemon.js.map +1 -0
- package/lib/commonjs/utils/createPersistedObservableStore.js +23 -3
- package/lib/commonjs/utils/createPersistedObservableStore.js.map +1 -1
- package/lib/commonjs/utils/daemonConnection.js +81 -0
- package/lib/commonjs/utils/daemonConnection.js.map +1 -0
- package/lib/commonjs/utils/daemonSettings.js +110 -0
- package/lib/commonjs/utils/daemonSettings.js.map +1 -0
- package/lib/commonjs/utils/reportToDaemon.js +112 -0
- package/lib/commonjs/utils/reportToDaemon.js.map +1 -0
- package/lib/commonjs/utils/sessionReport.js +132 -0
- package/lib/commonjs/utils/sessionReport.js.map +1 -0
- package/lib/commonjs/utils/streamToDaemon.js +334 -0
- package/lib/commonjs/utils/streamToDaemon.js.map +1 -0
- package/lib/module/features/network/index.js +25 -1
- package/lib/module/features/network/index.js.map +1 -1
- package/lib/module/features/network/networkInterceptor.js +14 -6
- package/lib/module/features/network/networkInterceptor.js.map +1 -1
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/panel/DebugPanel.js +26 -1
- package/lib/module/ui/panel/DebugPanel.js.map +1 -1
- package/lib/module/ui/panel/FloatPanelView.js +16 -63
- package/lib/module/ui/panel/FloatPanelView.js.map +1 -1
- package/lib/module/ui/panel/StreamingSettingsModal.js +524 -0
- package/lib/module/ui/panel/StreamingSettingsModal.js.map +1 -0
- package/lib/module/ui/panel/useTabAnimation.js +67 -0
- package/lib/module/ui/panel/useTabAnimation.js.map +1 -0
- package/lib/module/utils/autoDetectDaemon.js +136 -0
- package/lib/module/utils/autoDetectDaemon.js.map +1 -0
- package/lib/module/utils/createPersistedObservableStore.js +23 -3
- package/lib/module/utils/createPersistedObservableStore.js.map +1 -1
- package/lib/module/utils/daemonConnection.js +77 -0
- package/lib/module/utils/daemonConnection.js.map +1 -0
- package/lib/module/utils/daemonSettings.js +102 -0
- package/lib/module/utils/daemonSettings.js.map +1 -0
- package/lib/module/utils/reportToDaemon.js +105 -0
- package/lib/module/utils/reportToDaemon.js.map +1 -0
- package/lib/module/utils/sessionReport.js +128 -0
- package/lib/module/utils/sessionReport.js.map +1 -0
- package/lib/module/utils/streamToDaemon.js +328 -0
- package/lib/module/utils/streamToDaemon.js.map +1 -0
- package/lib/typescript/src/features/network/index.d.ts +2 -0
- package/lib/typescript/src/features/network/index.d.ts.map +1 -1
- package/lib/typescript/src/features/network/networkInterceptor.d.ts +1 -1
- package/lib/typescript/src/features/network/networkInterceptor.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +10 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/ui/panel/DebugPanel.d.ts.map +1 -1
- package/lib/typescript/src/ui/panel/FloatPanelView.d.ts.map +1 -1
- package/lib/typescript/src/ui/panel/StreamingSettingsModal.d.ts +8 -0
- package/lib/typescript/src/ui/panel/StreamingSettingsModal.d.ts.map +1 -0
- package/lib/typescript/src/ui/panel/useTabAnimation.d.ts +14 -0
- package/lib/typescript/src/ui/panel/useTabAnimation.d.ts.map +1 -0
- package/lib/typescript/src/utils/autoDetectDaemon.d.ts +15 -0
- package/lib/typescript/src/utils/autoDetectDaemon.d.ts.map +1 -0
- package/lib/typescript/src/utils/createPersistedObservableStore.d.ts +2 -1
- package/lib/typescript/src/utils/createPersistedObservableStore.d.ts.map +1 -1
- package/lib/typescript/src/utils/daemonConnection.d.ts +18 -0
- package/lib/typescript/src/utils/daemonConnection.d.ts.map +1 -0
- package/lib/typescript/src/utils/daemonSettings.d.ts +19 -0
- package/lib/typescript/src/utils/daemonSettings.d.ts.map +1 -0
- package/lib/typescript/src/utils/reportToDaemon.d.ts +34 -0
- package/lib/typescript/src/utils/reportToDaemon.d.ts.map +1 -0
- package/lib/typescript/src/utils/sessionReport.d.ts +18 -0
- package/lib/typescript/src/utils/sessionReport.d.ts.map +1 -0
- package/lib/typescript/src/utils/streamToDaemon.d.ts +23 -0
- package/lib/typescript/src/utils/streamToDaemon.d.ts.map +1 -0
- package/node/daemon/src/cli.js +75 -0
- package/node/daemon/src/console/console.html +936 -0
- package/node/daemon/src/console/index.js +47 -0
- package/node/daemon/src/constants.js +32 -0
- package/node/daemon/src/index.js +11 -0
- package/node/daemon/src/server.js +365 -0
- package/node/daemon/src/store.js +110 -0
- package/node/mcp/src/cli.js +31 -0
- package/node/mcp/src/constants.js +13 -0
- package/node/mcp/src/daemonClient.js +132 -0
- package/node/mcp/src/httpClient.js +49 -0
- package/node/mcp/src/index.js +15 -0
- package/node/mcp/src/logs.js +95 -0
- package/node/mcp/src/server.js +144 -0
- package/node/mcp/src/tools.js +84 -0
- package/package.json +7 -2
- package/src/features/network/index.ts +30 -3
- package/src/features/network/networkInterceptor.ts +19 -6
- package/src/index.ts +14 -0
- package/src/ui/panel/DebugPanel.tsx +23 -1
- package/src/ui/panel/FloatPanelView.tsx +10 -68
- package/src/ui/panel/StreamingSettingsModal.tsx +566 -0
- package/src/ui/panel/useTabAnimation.ts +77 -0
- package/src/utils/autoDetectDaemon.ts +175 -0
- package/src/utils/createPersistedObservableStore.ts +16 -3
- package/src/utils/daemonConnection.ts +133 -0
- package/src/utils/daemonSettings.ts +134 -0
- package/src/utils/reportToDaemon.ts +172 -0
- package/src/utils/sessionReport.ts +203 -0
- package/src/utils/streamToDaemon.ts +419 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { DebugToolkit } from '../core/DebugToolkit';
|
|
4
|
+
import { safeStringify } from './safeStringify';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_PER_TYPE = 50;
|
|
7
|
+
const DEFAULT_MAX_BODY_BYTES = 16 * 1024;
|
|
8
|
+
const MAX_DEPTH = 8;
|
|
9
|
+
|
|
10
|
+
export interface DebugSessionReportOptions {
|
|
11
|
+
maxPerType?: number;
|
|
12
|
+
maxBodyBytes?: number;
|
|
13
|
+
includeTypes?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DeviceInfo {
|
|
17
|
+
platform: string;
|
|
18
|
+
model: string;
|
|
19
|
+
osVersion: string;
|
|
20
|
+
appVersion: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DebugSessionReport {
|
|
24
|
+
version: 2;
|
|
25
|
+
device: DeviceInfo;
|
|
26
|
+
logs: Record<string, unknown[] | undefined>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TruncatedValue {
|
|
30
|
+
__debugToolkitTruncated: true;
|
|
31
|
+
originalBytes: number;
|
|
32
|
+
preview: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function utf8ByteLength(value: string): number {
|
|
36
|
+
let bytes = 0;
|
|
37
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
38
|
+
const code = value.charCodeAt(index);
|
|
39
|
+
if (code <= 0x7f) {
|
|
40
|
+
bytes += 1;
|
|
41
|
+
} else if (code <= 0x7ff) {
|
|
42
|
+
bytes += 2;
|
|
43
|
+
} else if (code >= 0xd800 && code <= 0xdbff) {
|
|
44
|
+
bytes += 4;
|
|
45
|
+
index += 1;
|
|
46
|
+
} else {
|
|
47
|
+
bytes += 3;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return bytes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function truncateUtf8(value: string, maxBytes: number): string {
|
|
54
|
+
if (utf8ByteLength(value) <= maxBytes) {
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let bytes = 0;
|
|
59
|
+
let result = '';
|
|
60
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
61
|
+
const code = value.charCodeAt(index);
|
|
62
|
+
const char = value[index]!;
|
|
63
|
+
const charBytes = code <= 0x7f
|
|
64
|
+
? 1
|
|
65
|
+
: code <= 0x7ff
|
|
66
|
+
? 2
|
|
67
|
+
: code >= 0xd800 && code <= 0xdbff
|
|
68
|
+
? 4
|
|
69
|
+
: 3;
|
|
70
|
+
|
|
71
|
+
if (bytes + charBytes > maxBytes) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
result += char;
|
|
76
|
+
bytes += charBytes;
|
|
77
|
+
|
|
78
|
+
if (charBytes === 4) {
|
|
79
|
+
index += 1;
|
|
80
|
+
result += value[index] ?? '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return `${result}...[truncated]`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function truncateLargeValue(value: unknown, maxBytes: number): unknown | TruncatedValue {
|
|
88
|
+
const serialized = safeStringify(value);
|
|
89
|
+
const originalBytes = utf8ByteLength(serialized);
|
|
90
|
+
if (originalBytes <= maxBytes) {
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
__debugToolkitTruncated: true,
|
|
96
|
+
originalBytes,
|
|
97
|
+
preview: truncateUtf8(serialized, maxBytes),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sanitizeValue(
|
|
102
|
+
value: unknown,
|
|
103
|
+
maxBodyBytes: number,
|
|
104
|
+
depth = 0,
|
|
105
|
+
seen = new WeakSet<object>(),
|
|
106
|
+
key = '',
|
|
107
|
+
): unknown {
|
|
108
|
+
if (typeof value === 'string') {
|
|
109
|
+
return truncateUtf8(value, maxBodyBytes);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
value === null ||
|
|
114
|
+
typeof value === 'number' ||
|
|
115
|
+
typeof value === 'boolean' ||
|
|
116
|
+
typeof value === 'undefined'
|
|
117
|
+
) {
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') {
|
|
122
|
+
return String(value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (value instanceof Date) {
|
|
126
|
+
return value.toISOString();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof value !== 'object') {
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const normalizedKey = key.toLowerCase();
|
|
134
|
+
if (normalizedKey === 'body' || normalizedKey === 'data') {
|
|
135
|
+
const normalized = sanitizeValue(value, maxBodyBytes, depth + 1, seen);
|
|
136
|
+
return truncateLargeValue(normalized, maxBodyBytes);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (seen.has(value)) {
|
|
140
|
+
return '[Circular]';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (depth >= MAX_DEPTH) {
|
|
144
|
+
return '[MaxDepth]';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
seen.add(value);
|
|
148
|
+
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
return value.map((item) => sanitizeValue(item, maxBodyBytes, depth + 1, seen));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return Object.entries(value as Record<string, unknown>).reduce<Record<string, unknown>>(
|
|
154
|
+
(acc, [entryKey, entryValue]) => {
|
|
155
|
+
acc[entryKey] = sanitizeValue(entryValue, maxBodyBytes, depth + 1, seen, entryKey);
|
|
156
|
+
return acc;
|
|
157
|
+
},
|
|
158
|
+
{},
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createDebugSessionReport(
|
|
163
|
+
options: DebugSessionReportOptions = {},
|
|
164
|
+
): DebugSessionReport {
|
|
165
|
+
const maxPerType = Math.max(1, Math.floor(options.maxPerType ?? DEFAULT_MAX_PER_TYPE));
|
|
166
|
+
const maxBodyBytes = Math.max(256, Math.floor(options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES));
|
|
167
|
+
const includeTypes = options.includeTypes?.length ? new Set(options.includeTypes) : null;
|
|
168
|
+
const logs: DebugSessionReport['logs'] = {};
|
|
169
|
+
|
|
170
|
+
DebugToolkit.features.forEach((feature) => {
|
|
171
|
+
if (includeTypes && !includeTypes.has(feature.name)) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let snapshot: unknown;
|
|
176
|
+
try {
|
|
177
|
+
snapshot = feature.getSnapshot();
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!Array.isArray(snapshot)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
logs[feature.name] = snapshot
|
|
187
|
+
.slice(-maxPerType)
|
|
188
|
+
.map((entry) => sanitizeValue(entry, maxBodyBytes));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const constants = Platform.constants as Record<string, unknown> | undefined;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
version: 2,
|
|
195
|
+
device: {
|
|
196
|
+
platform: Platform.OS,
|
|
197
|
+
model: (constants?.model as string) || 'unknown',
|
|
198
|
+
osVersion: Platform.Version == null ? 'unknown' : String(Platform.Version),
|
|
199
|
+
appVersion: (constants?.appVersion as string) || 'unknown',
|
|
200
|
+
},
|
|
201
|
+
logs,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { DebugToolkit } from '../core/DebugToolkit';
|
|
4
|
+
import { _addDaemonEndpointToNetworkBlacklist } from '../features/network';
|
|
5
|
+
import { createDebugSessionReport } from './sessionReport';
|
|
6
|
+
import {
|
|
7
|
+
buildDaemonUrl,
|
|
8
|
+
getDefaultDaemonEndpoint,
|
|
9
|
+
getGlobalFetch,
|
|
10
|
+
} from './reportToDaemon';
|
|
11
|
+
import { safeStringify } from './safeStringify';
|
|
12
|
+
|
|
13
|
+
export interface StreamToDaemonOptions {
|
|
14
|
+
endpoint?: string;
|
|
15
|
+
token?: string;
|
|
16
|
+
debounceMs?: number;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
onStatus?: (status: StreamStatus) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type StreamStatus =
|
|
22
|
+
| { state: 'connecting' }
|
|
23
|
+
| { state: 'connected'; sessionId: string }
|
|
24
|
+
| { state: 'retrying'; retryInMs: number }
|
|
25
|
+
| { state: 'failed'; reason: 'auth' | 'retry_limit' };
|
|
26
|
+
|
|
27
|
+
const DEFAULT_DEBOUNCE_MS = 200;
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
29
|
+
const RETRY_BASE_MS = 1000;
|
|
30
|
+
const MAX_RETRY_DELAY_MS = 30000;
|
|
31
|
+
const MAX_RETRY_ATTEMPTS = 10;
|
|
32
|
+
const BACKGROUND_RESYNC_THRESHOLD_MS = 5 * 60 * 1000;
|
|
33
|
+
type SendResult = 'ok' | 'retry' | 'auth_failed';
|
|
34
|
+
|
|
35
|
+
type FetchHeaders = Record<string, string>;
|
|
36
|
+
|
|
37
|
+
interface StreamState {
|
|
38
|
+
endpoint: string;
|
|
39
|
+
reportUrl: string;
|
|
40
|
+
ingestUrl: string;
|
|
41
|
+
token: string | undefined;
|
|
42
|
+
debounceMs: number;
|
|
43
|
+
timeoutMs: number;
|
|
44
|
+
sessionId: string | null;
|
|
45
|
+
sending: boolean;
|
|
46
|
+
debounceTimer: ReturnType<typeof setTimeout> | null;
|
|
47
|
+
retryTimer: ReturnType<typeof setTimeout> | null;
|
|
48
|
+
retryAttempt: number;
|
|
49
|
+
dirtyFeatures: Set<string>;
|
|
50
|
+
lastSentIds: Map<string, Set<string | number>>;
|
|
51
|
+
featureUnsubscribes: Array<() => void>;
|
|
52
|
+
appStateUnsubscribe: (() => void) | null;
|
|
53
|
+
backgroundedAt: number | null;
|
|
54
|
+
onStatus: ((status: StreamStatus) => void) | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let active: StreamState | null = null;
|
|
58
|
+
|
|
59
|
+
type AbortControllerLike = {
|
|
60
|
+
signal: unknown;
|
|
61
|
+
abort: () => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function createAbortController(): AbortControllerLike | undefined {
|
|
65
|
+
const AbortControllerCtor = (globalThis as {
|
|
66
|
+
AbortController?: new () => AbortControllerLike;
|
|
67
|
+
}).AbortController;
|
|
68
|
+
return AbortControllerCtor ? new AbortControllerCtor() : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getEntryId(entry: unknown): string | number | null {
|
|
72
|
+
if (!entry || typeof entry !== 'object') {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const id = (entry as Record<string, unknown>).id;
|
|
77
|
+
return typeof id === 'string' || typeof id === 'number' ? id : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function snapshotToIds(snapshot: unknown[]): Set<string | number> {
|
|
81
|
+
return new Set(
|
|
82
|
+
snapshot
|
|
83
|
+
.map(getEntryId)
|
|
84
|
+
.filter((id): id is string | number => id != null),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fetchHeaders(state: StreamState): FetchHeaders {
|
|
89
|
+
const headers: FetchHeaders = { 'Content-Type': 'application/json' };
|
|
90
|
+
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function emitStatus(state: StreamState, status: StreamStatus): void {
|
|
95
|
+
try {
|
|
96
|
+
state.onStatus?.(status);
|
|
97
|
+
} catch {
|
|
98
|
+
// Consumer status callbacks should not affect log delivery.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isAuthFailure(status: number): boolean {
|
|
103
|
+
return status === 401 || status === 403;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function failStreaming(state: StreamState, reason: 'auth' | 'retry_limit'): void {
|
|
107
|
+
if (active !== state) return;
|
|
108
|
+
emitStatus(state, { state: 'failed', reason });
|
|
109
|
+
stopStreaming();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function doPost(
|
|
113
|
+
url: string,
|
|
114
|
+
headers: FetchHeaders,
|
|
115
|
+
body: unknown,
|
|
116
|
+
timeoutMs: number,
|
|
117
|
+
): Promise<{ status: number; json?: () => Promise<unknown> } | null> {
|
|
118
|
+
const fetchImpl = getGlobalFetch();
|
|
119
|
+
if (!fetchImpl) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const controller = createAbortController();
|
|
123
|
+
const timeout = controller && timeoutMs > 0
|
|
124
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
125
|
+
: undefined;
|
|
126
|
+
try {
|
|
127
|
+
return await fetchImpl(url, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers,
|
|
130
|
+
body: safeStringify(body),
|
|
131
|
+
signal: controller?.signal,
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
} finally {
|
|
136
|
+
if (timeout) {
|
|
137
|
+
clearTimeout(timeout);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resetRetry(state: StreamState): void {
|
|
143
|
+
state.retryAttempt = 0;
|
|
144
|
+
if (state.retryTimer) {
|
|
145
|
+
clearTimeout(state.retryTimer);
|
|
146
|
+
state.retryTimer = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function scheduleRetry(state: StreamState): void {
|
|
151
|
+
if (state.retryTimer) return;
|
|
152
|
+
if (state.retryAttempt >= MAX_RETRY_ATTEMPTS) {
|
|
153
|
+
failStreaming(state, 'retry_limit');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const delay = Math.min(
|
|
157
|
+
RETRY_BASE_MS * (2 ** state.retryAttempt),
|
|
158
|
+
MAX_RETRY_DELAY_MS,
|
|
159
|
+
);
|
|
160
|
+
state.retryAttempt += 1;
|
|
161
|
+
emitStatus(state, { state: 'retrying', retryInMs: delay });
|
|
162
|
+
state.retryTimer = setTimeout(() => {
|
|
163
|
+
state.retryTimer = null;
|
|
164
|
+
if (active !== state) return;
|
|
165
|
+
if (state.sessionId) {
|
|
166
|
+
sendDelta(state);
|
|
167
|
+
} else {
|
|
168
|
+
sendFullReport(state);
|
|
169
|
+
}
|
|
170
|
+
}, delay);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function sendFullReport(state: StreamState): Promise<void> {
|
|
174
|
+
if (state.sending) return;
|
|
175
|
+
state.sending = true;
|
|
176
|
+
let result: SendResult = 'ok';
|
|
177
|
+
try {
|
|
178
|
+
result = await doSendFullReport(state);
|
|
179
|
+
if (result === 'ok') resetRetry(state);
|
|
180
|
+
} finally {
|
|
181
|
+
state.sending = false;
|
|
182
|
+
if (active !== state) return;
|
|
183
|
+
if (result === 'auth_failed') {
|
|
184
|
+
failStreaming(state, 'auth');
|
|
185
|
+
} else if (result === 'retry') {
|
|
186
|
+
scheduleRetry(state);
|
|
187
|
+
} else if (state.dirtyFeatures.size > 0 && !state.debounceTimer) {
|
|
188
|
+
scheduleDelta(state);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function doSendFullReport(state: StreamState): Promise<SendResult> {
|
|
194
|
+
const report = createDebugSessionReport();
|
|
195
|
+
const response = await doPost(state.reportUrl, fetchHeaders(state), report, state.timeoutMs);
|
|
196
|
+
if (!response) return 'retry';
|
|
197
|
+
if (isAuthFailure(response.status)) return 'auth_failed';
|
|
198
|
+
if (response.status < 200 || response.status >= 300) return 'retry';
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const body = response.json
|
|
202
|
+
? (await response.json()) as Record<string, unknown> | null
|
|
203
|
+
: null;
|
|
204
|
+
if (body?.ok !== true || typeof body.sessionId !== 'string') return 'retry';
|
|
205
|
+
state.sessionId = body.sessionId;
|
|
206
|
+
emitStatus(state, { state: 'connected', sessionId: body.sessionId });
|
|
207
|
+
} catch {
|
|
208
|
+
return 'retry';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
state.lastSentIds.clear();
|
|
212
|
+
for (const feature of DebugToolkit.features) {
|
|
213
|
+
try {
|
|
214
|
+
const snapshot = feature.getSnapshot();
|
|
215
|
+
if (Array.isArray(snapshot)) state.lastSentIds.set(feature.name, snapshotToIds(snapshot));
|
|
216
|
+
} catch {
|
|
217
|
+
// skip
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return 'ok';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function sendDelta(state: StreamState): Promise<void> {
|
|
224
|
+
if (state.sending || state.dirtyFeatures.size === 0) return;
|
|
225
|
+
state.sending = true;
|
|
226
|
+
let retry = false;
|
|
227
|
+
try {
|
|
228
|
+
const delta: Record<string, unknown[]> = {};
|
|
229
|
+
const nextSentIds = new Map<string, Set<string | number>>();
|
|
230
|
+
const features = DebugToolkit.features;
|
|
231
|
+
|
|
232
|
+
for (const featureName of state.dirtyFeatures) {
|
|
233
|
+
const feature = features.find((f) => f.name === featureName);
|
|
234
|
+
if (!feature) continue;
|
|
235
|
+
|
|
236
|
+
let snapshot: unknown;
|
|
237
|
+
try {
|
|
238
|
+
snapshot = feature.getSnapshot();
|
|
239
|
+
} catch {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!Array.isArray(snapshot)) continue;
|
|
244
|
+
|
|
245
|
+
const prevIds = state.lastSentIds.get(featureName) || new Set<string | number>();
|
|
246
|
+
const newEntries = snapshot.filter(
|
|
247
|
+
(entry) => {
|
|
248
|
+
const id = getEntryId(entry);
|
|
249
|
+
return id != null && !prevIds.has(id);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (newEntries.length > 0) {
|
|
254
|
+
delta[featureName] = newEntries;
|
|
255
|
+
nextSentIds.set(featureName, snapshotToIds(snapshot));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
state.dirtyFeatures.clear();
|
|
260
|
+
state.debounceTimer = null;
|
|
261
|
+
|
|
262
|
+
if (Object.keys(delta).length === 0) return;
|
|
263
|
+
|
|
264
|
+
if (!state.sessionId) {
|
|
265
|
+
const result = await doSendFullReport(state);
|
|
266
|
+
retry = result === 'retry';
|
|
267
|
+
if (result !== 'ok') Object.keys(delta).forEach((featureName) => state.dirtyFeatures.add(featureName));
|
|
268
|
+
if (result === 'auth_failed') failStreaming(state, 'auth');
|
|
269
|
+
if (result === 'ok') resetRetry(state);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const response = await doPost(
|
|
274
|
+
state.ingestUrl,
|
|
275
|
+
fetchHeaders(state),
|
|
276
|
+
{ sessionId: state.sessionId, delta: { logs: delta } },
|
|
277
|
+
state.timeoutMs,
|
|
278
|
+
);
|
|
279
|
+
if (!response) {
|
|
280
|
+
Object.keys(delta).forEach((featureName) => state.dirtyFeatures.add(featureName));
|
|
281
|
+
retry = true;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (response.status === 404) {
|
|
286
|
+
state.sessionId = null;
|
|
287
|
+
state.lastSentIds.clear();
|
|
288
|
+
const result = await doSendFullReport(state);
|
|
289
|
+
retry = result === 'retry';
|
|
290
|
+
if (result !== 'ok') Object.keys(delta).forEach((featureName) => state.dirtyFeatures.add(featureName));
|
|
291
|
+
if (result === 'auth_failed') failStreaming(state, 'auth');
|
|
292
|
+
if (result === 'ok') resetRetry(state);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (isAuthFailure(response.status)) {
|
|
297
|
+
Object.keys(delta).forEach((featureName) => state.dirtyFeatures.add(featureName));
|
|
298
|
+
failStreaming(state, 'auth');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (response.status < 200 || response.status >= 300) {
|
|
303
|
+
Object.keys(delta).forEach((featureName) => state.dirtyFeatures.add(featureName));
|
|
304
|
+
retry = true;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
nextSentIds.forEach((ids, featureName) => {
|
|
309
|
+
state.lastSentIds.set(featureName, ids);
|
|
310
|
+
});
|
|
311
|
+
resetRetry(state);
|
|
312
|
+
if (state.sessionId) {
|
|
313
|
+
emitStatus(state, { state: 'connected', sessionId: state.sessionId });
|
|
314
|
+
}
|
|
315
|
+
} finally {
|
|
316
|
+
state.sending = false;
|
|
317
|
+
if (active !== state) return;
|
|
318
|
+
if (retry && state.dirtyFeatures.size > 0) {
|
|
319
|
+
scheduleRetry(state);
|
|
320
|
+
} else if (state.dirtyFeatures.size > 0 && !state.debounceTimer) {
|
|
321
|
+
scheduleDelta(state);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function scheduleDelta(state: StreamState): void {
|
|
327
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
328
|
+
state.debounceTimer = setTimeout(() => {
|
|
329
|
+
state.debounceTimer = null;
|
|
330
|
+
if (active === state) sendDelta(state);
|
|
331
|
+
}, state.debounceMs);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function onFeatureChange(featureName: string): void {
|
|
335
|
+
if (!active) return;
|
|
336
|
+
active.dirtyFeatures.add(featureName);
|
|
337
|
+
if (active.retryTimer) return;
|
|
338
|
+
scheduleDelta(active);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function handleAppStateChange(nextState: AppStateStatus): void {
|
|
342
|
+
if (!active) return;
|
|
343
|
+
if (nextState === 'background') {
|
|
344
|
+
active.backgroundedAt = Date.now();
|
|
345
|
+
if (active.debounceTimer) {
|
|
346
|
+
clearTimeout(active.debounceTimer);
|
|
347
|
+
active.debounceTimer = null;
|
|
348
|
+
}
|
|
349
|
+
sendDelta(active);
|
|
350
|
+
} else if (nextState === 'active') {
|
|
351
|
+
const wasAway = active.backgroundedAt ? Date.now() - active.backgroundedAt : 0;
|
|
352
|
+
active.backgroundedAt = null;
|
|
353
|
+
|
|
354
|
+
if (wasAway > BACKGROUND_RESYNC_THRESHOLD_MS || !active.sessionId) {
|
|
355
|
+
active.sessionId = null;
|
|
356
|
+
active.lastSentIds.clear();
|
|
357
|
+
sendFullReport(active);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function startStreaming(options: StreamToDaemonOptions = {}): void {
|
|
363
|
+
if (active) return;
|
|
364
|
+
|
|
365
|
+
const endpoint = options.endpoint || getDefaultDaemonEndpoint();
|
|
366
|
+
const reportUrl = buildDaemonUrl(endpoint, '/report');
|
|
367
|
+
const ingestUrl = buildDaemonUrl(endpoint, '/ingest');
|
|
368
|
+
|
|
369
|
+
_addDaemonEndpointToNetworkBlacklist(endpoint);
|
|
370
|
+
_addDaemonEndpointToNetworkBlacklist(reportUrl);
|
|
371
|
+
_addDaemonEndpointToNetworkBlacklist(ingestUrl);
|
|
372
|
+
|
|
373
|
+
const state: StreamState = {
|
|
374
|
+
endpoint,
|
|
375
|
+
reportUrl,
|
|
376
|
+
ingestUrl,
|
|
377
|
+
token: options.token,
|
|
378
|
+
debounceMs: options.debounceMs || DEFAULT_DEBOUNCE_MS,
|
|
379
|
+
timeoutMs: Math.max(0, options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
380
|
+
sessionId: null,
|
|
381
|
+
sending: false,
|
|
382
|
+
debounceTimer: null,
|
|
383
|
+
retryTimer: null,
|
|
384
|
+
retryAttempt: 0,
|
|
385
|
+
dirtyFeatures: new Set(),
|
|
386
|
+
lastSentIds: new Map(),
|
|
387
|
+
featureUnsubscribes: [],
|
|
388
|
+
appStateUnsubscribe: null,
|
|
389
|
+
backgroundedAt: null,
|
|
390
|
+
onStatus: options.onStatus,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
for (const feature of DebugToolkit.features) {
|
|
394
|
+
if (!feature.subscribe) continue;
|
|
395
|
+
const unsub = feature.subscribe(() => { onFeatureChange(feature.name); });
|
|
396
|
+
state.featureUnsubscribes.push(unsub);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
state.appStateUnsubscribe = AppState.addEventListener('change', handleAppStateChange).remove;
|
|
400
|
+
active = state;
|
|
401
|
+
|
|
402
|
+
emitStatus(active, { state: 'connecting' });
|
|
403
|
+
sendFullReport(active);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function stopStreaming(): void {
|
|
407
|
+
if (!active) return;
|
|
408
|
+
const state = active;
|
|
409
|
+
active = null;
|
|
410
|
+
|
|
411
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
412
|
+
if (state.retryTimer) clearTimeout(state.retryTimer);
|
|
413
|
+
state.featureUnsubscribes.forEach((fn) => fn());
|
|
414
|
+
state.appStateUnsubscribe?.();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function isStreaming(): boolean {
|
|
418
|
+
return active !== null;
|
|
419
|
+
}
|