@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,254 @@
1
+ 'use strict';
2
+
3
+ (function exposeSignalRSessionFormat(root) {
4
+ const FORMAT = 'signalr-inspector-session';
5
+ const VERSION = 1;
6
+ const MAX_MESSAGES = 500;
7
+ const MAX_STORED_CHARACTERS = 10 * 1024 * 1024;
8
+ const MAX_STRING_LENGTH = 350_000;
9
+ const MAX_FILE_CHARACTERS = 64 * 1024 * 1024;
10
+ const SENSITIVE_QUERY_PARAMETERS = ['id', 'access_token', 'accessToken'];
11
+ // Keep this validation contract in sync with contentScript.js and background.js.
12
+ const ALLOWED_TRANSPORTS = new Set([
13
+ 'websocket',
14
+ 'server-sent events',
15
+ 'long polling',
16
+ 'negotiation',
17
+ ]);
18
+ const ALLOWED_DIRECTIONS = new Set(['incoming', 'outgoing']);
19
+ const ALLOWED_LIFECYCLE_EVENTS = new Set([
20
+ 'negotiate',
21
+ 'azure-signalr-redirect',
22
+ 'transport-open',
23
+ 'transport-close',
24
+ 'transport-error',
25
+ ]);
26
+ const OPTIONAL_STRING_FIELDS = [
27
+ 'preview',
28
+ 'textPayload',
29
+ 'base64Payload',
30
+ 'encoding',
31
+ 'error',
32
+ 'lifecycleDetail',
33
+ ];
34
+ const STORED_STRING_FIELDS = ['endpoint', ...OPTIONAL_STRING_FIELDS, 'documentId'];
35
+
36
+ function fail(message) {
37
+ throw new Error(`Invalid SignalR Inspector session: ${message}`);
38
+ }
39
+
40
+ function sanitizeEndpoint(endpoint) {
41
+ if (endpoint === '') {
42
+ return '';
43
+ }
44
+ try {
45
+ const sanitized = new URL(endpoint);
46
+ for (const parameter of SENSITIVE_QUERY_PARAMETERS) {
47
+ sanitized.searchParams.delete(parameter);
48
+ }
49
+ return sanitized.toString();
50
+ } catch {
51
+ return '';
52
+ }
53
+ }
54
+
55
+ function countStoredCharacters(message) {
56
+ return STORED_STRING_FIELDS.reduce(
57
+ (total, key) => total + (typeof message[key] === 'string' ? message[key].length : 0),
58
+ 0,
59
+ );
60
+ }
61
+
62
+ function normalizeMessage(message, index) {
63
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
64
+ fail(`message ${index + 1} must be an object`);
65
+ }
66
+ if (!ALLOWED_TRANSPORTS.has(message.transport)) {
67
+ fail(`message ${index + 1} has an unsupported transport`);
68
+ }
69
+ if (!ALLOWED_DIRECTIONS.has(message.direction)) {
70
+ fail(`message ${index + 1} has an unsupported direction`);
71
+ }
72
+ if (typeof message.endpoint !== 'string' || message.endpoint.length > 4096) {
73
+ fail(`message ${index + 1} has an invalid endpoint`);
74
+ }
75
+ if (!Number.isFinite(message.timestamp)) {
76
+ fail(`message ${index + 1} has an invalid timestamp`);
77
+ }
78
+ if (message.size !== null && (!Number.isFinite(message.size) || message.size < 0)) {
79
+ fail(`message ${index + 1} has an invalid size`);
80
+ }
81
+ if (message.truncated !== undefined && typeof message.truncated !== 'boolean') {
82
+ fail(`message ${index + 1} has an invalid truncated flag`);
83
+ }
84
+ if (
85
+ message.connectionSeq !== undefined &&
86
+ (!Number.isSafeInteger(message.connectionSeq) || message.connectionSeq <= 0)
87
+ ) {
88
+ fail(`message ${index + 1} has an invalid connection sequence`);
89
+ }
90
+ if (
91
+ message.documentId !== undefined &&
92
+ (typeof message.documentId !== 'string' || message.documentId.length > 256)
93
+ ) {
94
+ fail(`message ${index + 1} has an invalid document identity`);
95
+ }
96
+ for (const key of OPTIONAL_STRING_FIELDS) {
97
+ if (
98
+ message[key] !== undefined &&
99
+ (typeof message[key] !== 'string' || message[key].length > MAX_STRING_LENGTH)
100
+ ) {
101
+ fail(`message ${index + 1} has an invalid ${key}`);
102
+ }
103
+ }
104
+
105
+ const hasLifecycleEvent = message.lifecycleEvent !== undefined;
106
+ if ((message.encoding === 'lifecycle') !== hasLifecycleEvent) {
107
+ fail(`message ${index + 1} has inconsistent lifecycle metadata`);
108
+ }
109
+ if (
110
+ hasLifecycleEvent &&
111
+ (!ALLOWED_LIFECYCLE_EVENTS.has(message.lifecycleEvent) ||
112
+ typeof message.lifecycleDetail !== 'string' ||
113
+ message.lifecycleDetail.length > 4096 ||
114
+ message.textPayload !== undefined ||
115
+ message.base64Payload !== undefined)
116
+ ) {
117
+ fail(`message ${index + 1} has invalid lifecycle metadata`);
118
+ }
119
+ if (!hasLifecycleEvent && message.lifecycleDetail !== undefined) {
120
+ fail(`message ${index + 1} has lifecycle detail without an event`);
121
+ }
122
+
123
+ const normalized = {
124
+ transport: message.transport,
125
+ direction: message.direction,
126
+ endpoint: sanitizeEndpoint(message.endpoint),
127
+ timestamp: message.timestamp,
128
+ size: message.size,
129
+ };
130
+ for (const key of [
131
+ ...OPTIONAL_STRING_FIELDS,
132
+ 'truncated',
133
+ 'lifecycleEvent',
134
+ 'connectionSeq',
135
+ 'documentId',
136
+ ]) {
137
+ if (message[key] !== undefined) {
138
+ normalized[key] = message[key];
139
+ }
140
+ }
141
+ return normalized;
142
+ }
143
+
144
+ function normalizeMessages(messages) {
145
+ if (!Array.isArray(messages)) {
146
+ fail('messages must be an array');
147
+ }
148
+ if (messages.length > MAX_MESSAGES) {
149
+ fail(`sessions can contain at most ${MAX_MESSAGES} messages`);
150
+ }
151
+ const normalized = messages.map(normalizeMessage);
152
+ const storedCharacters = normalized.reduce(
153
+ (total, message) => total + countStoredCharacters(message),
154
+ 0,
155
+ );
156
+ if (storedCharacters > MAX_STORED_CHARACTERS) {
157
+ fail('captured text exceeds the 10 MiB session limit');
158
+ }
159
+ return normalized;
160
+ }
161
+
162
+ function normalizeExportedAt(exportedAt) {
163
+ if (typeof exportedAt !== 'string') {
164
+ fail('exportedAt must be an ISO timestamp');
165
+ }
166
+ const parsed = new Date(exportedAt);
167
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== exportedAt) {
168
+ fail('exportedAt must be an ISO timestamp');
169
+ }
170
+ return exportedAt;
171
+ }
172
+
173
+ function create(messages, exportedAt = new Date().toISOString()) {
174
+ return {
175
+ format: FORMAT,
176
+ version: VERSION,
177
+ exportedAt: normalizeExportedAt(exportedAt),
178
+ messages: normalizeMessages(messages),
179
+ };
180
+ }
181
+
182
+ function pseudonymizeDocumentIdentities(messages) {
183
+ const documentIdentities = new Map();
184
+ return messages.map((message) => {
185
+ if (message.documentId === undefined) {
186
+ return message;
187
+ }
188
+ if (!documentIdentities.has(message.documentId)) {
189
+ documentIdentities.set(message.documentId, `document-${documentIdentities.size + 1}`);
190
+ }
191
+ return { ...message, documentId: documentIdentities.get(message.documentId) };
192
+ });
193
+ }
194
+
195
+ function serialize(messages, exportedAt) {
196
+ const session = create(messages, exportedAt);
197
+ session.messages = pseudonymizeDocumentIdentities(session.messages);
198
+ const serialized = JSON.stringify(session, null, 2);
199
+ if (serialized.length > MAX_FILE_CHARACTERS) {
200
+ fail('serialized data exceeds the 64 MiB file limit');
201
+ }
202
+ return serialized;
203
+ }
204
+
205
+ function parse(serialized) {
206
+ if (typeof serialized !== 'string') {
207
+ fail('file contents must be text');
208
+ }
209
+ if (serialized.length > MAX_FILE_CHARACTERS) {
210
+ fail('file exceeds the 64 MiB limit');
211
+ }
212
+ let session;
213
+ try {
214
+ session = JSON.parse(serialized);
215
+ } catch {
216
+ fail('file is not valid JSON');
217
+ }
218
+ if (!session || typeof session !== 'object' || Array.isArray(session)) {
219
+ fail('root value must be an object');
220
+ }
221
+ if (session.format !== FORMAT) {
222
+ fail(`format must be ${FORMAT}`);
223
+ }
224
+ if (session.version !== VERSION) {
225
+ fail(`unsupported format version ${String(session.version)}`);
226
+ }
227
+ return {
228
+ format: FORMAT,
229
+ version: VERSION,
230
+ exportedAt: normalizeExportedAt(session.exportedAt),
231
+ messages: normalizeMessages(session.messages),
232
+ };
233
+ }
234
+
235
+ const api = {
236
+ FORMAT,
237
+ VERSION,
238
+ MAX_FILE_CHARACTERS,
239
+ create,
240
+ parse,
241
+ serialize,
242
+ };
243
+ root.SignalRSessionFormat = api;
244
+ if (typeof module !== 'undefined' && module.exports) {
245
+ module.exports = api;
246
+ // Keep these explicit assignments detectable as synthetic named exports for Node ESM.
247
+ module.exports.FORMAT = FORMAT;
248
+ module.exports.VERSION = VERSION;
249
+ module.exports.MAX_FILE_CHARACTERS = MAX_FILE_CHARACTERS;
250
+ module.exports.create = create;
251
+ module.exports.parse = parse;
252
+ module.exports.serialize = serialize;
253
+ }
254
+ })(globalThis);