@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.
@@ -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);