@signalr-devtools/analysis 0.1.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/LICENSE +18 -0
- package/README.md +89 -0
- package/msgpackDecoder.js +371 -0
- package/package.json +45 -0
- package/sessionFormat.js +254 -0
- package/signalrAnalysis.js +798 -0
- package/signalrProtocol.js +324 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
(function exposeSignalRProtocol(root) {
|
|
4
|
+
const RECORD_SEPARATOR = '\u001e';
|
|
5
|
+
const MESSAGE_TYPES = {
|
|
6
|
+
1: 'Invocation',
|
|
7
|
+
2: 'Stream item',
|
|
8
|
+
3: 'Completion',
|
|
9
|
+
4: 'Stream invocation',
|
|
10
|
+
5: 'Cancel invocation',
|
|
11
|
+
6: 'Ping',
|
|
12
|
+
7: 'Close',
|
|
13
|
+
8: 'Acknowledgement',
|
|
14
|
+
9: 'Sequence',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function describeHubMessage(value) {
|
|
18
|
+
const kind = MESSAGE_TYPES[value?.type] || `Unknown (${value?.type ?? '?'})`;
|
|
19
|
+
let summary = '';
|
|
20
|
+
if (value?.target) {
|
|
21
|
+
summary = value.target;
|
|
22
|
+
} else if (value?.error) {
|
|
23
|
+
summary = value.error;
|
|
24
|
+
} else if (value?.invocationId !== undefined) {
|
|
25
|
+
summary = `Invocation ${value.invocationId}`;
|
|
26
|
+
} else if (value?.sequenceId !== undefined) {
|
|
27
|
+
summary = `Sequence ${value.sequenceId}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
kind,
|
|
32
|
+
target: value?.target || '',
|
|
33
|
+
invocationId: value?.invocationId,
|
|
34
|
+
summary,
|
|
35
|
+
value,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseRecord(record) {
|
|
40
|
+
let value;
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(record);
|
|
43
|
+
} catch {
|
|
44
|
+
return { kind: 'Text', summary: record, raw: record };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (value && typeof value.protocol === 'string' && Number.isInteger(value.version)) {
|
|
48
|
+
return {
|
|
49
|
+
kind: 'Handshake',
|
|
50
|
+
summary: `${value.protocol} protocol v${value.version}`,
|
|
51
|
+
value,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && value.type === undefined) {
|
|
56
|
+
return {
|
|
57
|
+
kind: value.error ? 'Handshake error' : 'Handshake response',
|
|
58
|
+
summary: value.error || 'Connection accepted',
|
|
59
|
+
value,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return describeHubMessage(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function base64ToBytes(value) {
|
|
67
|
+
const binary = atob(value.replace(/\s/g, ''));
|
|
68
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nonEmptyHeaders(headers) {
|
|
72
|
+
return headers && typeof headers === 'object' && Object.keys(headers).length > 0
|
|
73
|
+
? headers
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function messagePackValue(array) {
|
|
78
|
+
if (!Array.isArray(array) || !Number.isInteger(array[0])) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const type = array[0];
|
|
83
|
+
const headers = nonEmptyHeaders(array[1]);
|
|
84
|
+
let value;
|
|
85
|
+
switch (type) {
|
|
86
|
+
case 1:
|
|
87
|
+
case 4:
|
|
88
|
+
value = {
|
|
89
|
+
type,
|
|
90
|
+
headers,
|
|
91
|
+
invocationId: array[2] ?? undefined,
|
|
92
|
+
target: array[3],
|
|
93
|
+
arguments: array[4],
|
|
94
|
+
streamIds: array[5],
|
|
95
|
+
};
|
|
96
|
+
break;
|
|
97
|
+
case 2:
|
|
98
|
+
value = { type, headers, invocationId: array[2], item: array[3] };
|
|
99
|
+
break;
|
|
100
|
+
case 3: {
|
|
101
|
+
value = { type, headers, invocationId: array[2] };
|
|
102
|
+
const resultKind = array[3];
|
|
103
|
+
if (resultKind === 1) {
|
|
104
|
+
value.error = array[4];
|
|
105
|
+
} else if (resultKind === 3) {
|
|
106
|
+
value.result = array[4];
|
|
107
|
+
} else if (resultKind !== 2) {
|
|
108
|
+
value.resultKind = resultKind;
|
|
109
|
+
value.result = array[4];
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 5:
|
|
114
|
+
value = { type, headers, invocationId: array[2] };
|
|
115
|
+
break;
|
|
116
|
+
case 6:
|
|
117
|
+
value = { type };
|
|
118
|
+
break;
|
|
119
|
+
case 7:
|
|
120
|
+
value = { type, error: array[1] ?? undefined, allowReconnect: array[2] };
|
|
121
|
+
break;
|
|
122
|
+
case 8:
|
|
123
|
+
case 9:
|
|
124
|
+
value = { type, sequenceId: array[1] };
|
|
125
|
+
break;
|
|
126
|
+
default:
|
|
127
|
+
value = { type, messagePack: array.slice(1) };
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const key of Object.keys(value)) {
|
|
132
|
+
if (value[key] === undefined) {
|
|
133
|
+
delete value[key];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function summarizeRecords(records, protocol) {
|
|
140
|
+
return {
|
|
141
|
+
kind: records.map((record) => record.kind).join(' + '),
|
|
142
|
+
target: records.find((record) => record.target)?.target || '',
|
|
143
|
+
invocationId: records.find((record) => record.invocationId !== undefined)?.invocationId,
|
|
144
|
+
summary: records
|
|
145
|
+
.map((record) => record.summary)
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.join(' · '),
|
|
148
|
+
records,
|
|
149
|
+
protocol,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseBinaryHandshake(bytes) {
|
|
154
|
+
if (!root.TextDecoder) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let text;
|
|
159
|
+
try {
|
|
160
|
+
text = new root.TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (!text.endsWith(RECORD_SEPARATOR)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const records = text.split(RECORD_SEPARATOR).filter(Boolean).map(parseRecord);
|
|
169
|
+
if (
|
|
170
|
+
records.length === 0 ||
|
|
171
|
+
records.some(
|
|
172
|
+
(record) =>
|
|
173
|
+
record.kind !== 'Handshake' &&
|
|
174
|
+
record.kind !== 'Handshake response' &&
|
|
175
|
+
record.kind !== 'Handshake error',
|
|
176
|
+
)
|
|
177
|
+
) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
return summarizeRecords(records, 'binary-handshake');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseBinaryPayload(message) {
|
|
184
|
+
if (message?.truncated || typeof message?.base64Payload !== 'string' || !root.SignalRMsgPack) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let bytes;
|
|
189
|
+
try {
|
|
190
|
+
bytes = base64ToBytes(message.base64Payload);
|
|
191
|
+
} catch {
|
|
192
|
+
return {
|
|
193
|
+
kind: 'Binary',
|
|
194
|
+
summary: 'Invalid Base64 payload',
|
|
195
|
+
records: [],
|
|
196
|
+
diagnostic: 'The captured binary payload is not valid Base64.',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const handshake = parseBinaryHandshake(bytes);
|
|
201
|
+
if (handshake) {
|
|
202
|
+
return handshake;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const frames = root.SignalRMsgPack.decodeVarIntFrames(bytes);
|
|
206
|
+
const records = [];
|
|
207
|
+
const diagnostics = [];
|
|
208
|
+
for (const frame of frames) {
|
|
209
|
+
const decoded = root.SignalRMsgPack.decode(frame);
|
|
210
|
+
if (decoded.error) {
|
|
211
|
+
diagnostics.push(decoded.error);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (decoded.bytesRead !== frame.length) {
|
|
215
|
+
diagnostics.push(
|
|
216
|
+
`MessagePack frame has ${frame.length - decoded.bytesRead} trailing byte(s).`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const value = messagePackValue(decoded.value);
|
|
220
|
+
if (!value) {
|
|
221
|
+
diagnostics.push('MessagePack frame is not a SignalR hub message array.');
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
records.push({ ...describeHubMessage(value), protocol: 'messagepack' });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (frames.incomplete) {
|
|
228
|
+
diagnostics.push('The captured SignalR binary frame is incomplete.');
|
|
229
|
+
}
|
|
230
|
+
if (frames.error) {
|
|
231
|
+
diagnostics.push(frames.error);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (records.length === 0) {
|
|
235
|
+
return {
|
|
236
|
+
kind: 'Binary',
|
|
237
|
+
summary: diagnostics[0] || message.preview || '',
|
|
238
|
+
records,
|
|
239
|
+
diagnostic: diagnostics.join(' '),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { ...summarizeRecords(records, 'messagepack'), diagnostic: diagnostics.join(' ') };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parsePayload(message) {
|
|
247
|
+
if (message?.encoding === 'lifecycle') {
|
|
248
|
+
const labels = {
|
|
249
|
+
negotiate: 'Negotiate',
|
|
250
|
+
'transport-open': 'Transport connected',
|
|
251
|
+
'transport-close': 'Transport disconnected',
|
|
252
|
+
'transport-error': 'Transport error',
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
kind: labels[message.lifecycleEvent] || 'Lifecycle',
|
|
256
|
+
summary: message.lifecycleDetail || message.preview || '',
|
|
257
|
+
records: [],
|
|
258
|
+
lifecycleEvent: message.lifecycleEvent,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (message?.encoding !== 'text' || typeof message.textPayload !== 'string') {
|
|
262
|
+
const binary = parseBinaryPayload(message);
|
|
263
|
+
if (binary) {
|
|
264
|
+
return binary;
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
kind: message?.encoding?.startsWith('blob:') ? 'Binary' : message?.encoding || 'Binary',
|
|
268
|
+
summary: message?.preview || '',
|
|
269
|
+
records: [],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const records = message.textPayload.split(RECORD_SEPARATOR).filter(Boolean).map(parseRecord);
|
|
274
|
+
|
|
275
|
+
if (records.length === 0) {
|
|
276
|
+
return { kind: 'Empty', summary: '', records: [] };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
kind: records.map((record) => record.kind).join(' + '),
|
|
281
|
+
target: records.find((record) => record.target)?.target || '',
|
|
282
|
+
invocationId: records.find((record) => record.invocationId !== undefined)?.invocationId,
|
|
283
|
+
summary: records
|
|
284
|
+
.map((record) => record.summary)
|
|
285
|
+
.filter(Boolean)
|
|
286
|
+
.join(' · '),
|
|
287
|
+
records,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function formatPayload(message) {
|
|
292
|
+
const parsed = parsePayload(message);
|
|
293
|
+
if (parsed.records.length === 0) {
|
|
294
|
+
const payload = message?.base64Payload
|
|
295
|
+
? `Base64 (${message.encoding || 'binary'}):\n${message.base64Payload}`
|
|
296
|
+
: message?.textPayload || message?.preview || '(no data)';
|
|
297
|
+
return parsed.diagnostic ? `${parsed.diagnostic}\n\n${payload}` : payload;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const formatted = parsed.records
|
|
301
|
+
.map((record) => {
|
|
302
|
+
if (record.value === undefined) {
|
|
303
|
+
return record.raw;
|
|
304
|
+
}
|
|
305
|
+
return JSON.stringify(record.value, null, 2);
|
|
306
|
+
})
|
|
307
|
+
.join('\n\n');
|
|
308
|
+
if (!message?.base64Payload) {
|
|
309
|
+
return formatted;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const diagnostic = parsed.diagnostic ? `${parsed.diagnostic}\n\n` : '';
|
|
313
|
+
return `${diagnostic}${formatted}\n\nRaw Base64:\n${message.base64Payload}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const api = { parsePayload, formatPayload };
|
|
317
|
+
root.SignalRProtocol = api;
|
|
318
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
319
|
+
module.exports = api;
|
|
320
|
+
// Keep these explicit assignments detectable as synthetic named exports for Node ESM.
|
|
321
|
+
module.exports.parsePayload = parsePayload;
|
|
322
|
+
module.exports.formatPayload = formatPayload;
|
|
323
|
+
}
|
|
324
|
+
})(globalThis);
|