nowaikit-utils 1.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,627 @@
1
+ /**
2
+ * NowAIKit Utils — Integration Bridge
3
+ *
4
+ * WebSocket client that connects the browser extension to the NowAIKit Builder
5
+ * VS Code extension, turning the browser extension into part of an integrated
6
+ * product suite.
7
+ *
8
+ * Protocol:
9
+ * Browser -> Builder: { type: 'api-request', id, method, params }
10
+ * Builder -> Browser: { type: 'api-response', id, success, result|error }
11
+ * Browser -> Builder: { type: 'script-change', table, sys_id, field, name, scope, content }
12
+ * Builder -> Browser: { type: 'script-save', table, sys_id, field, name, scope, content }
13
+ *
14
+ * Not wrapped in IIFE — functions are exposed globally for content.js and ai-sidebar.js.
15
+ * Uses `var` and top-level `function` declarations for content script compatibility.
16
+ */
17
+
18
+ // ─── Bridge State ──────────────────────────────────────────────
19
+
20
+ /** @type {WebSocket|null} */
21
+ var _bridgeSocket = null;
22
+
23
+ /** @type {boolean} */
24
+ var _bridgeConnected = false;
25
+
26
+ /** @type {string} */
27
+ var _bridgeInstanceName = '';
28
+
29
+ /** @type {string} */
30
+ var _bridgeInstanceUrl = '';
31
+
32
+ /** @type {number} */
33
+ var _bridgeReconnectAttempts = 0;
34
+
35
+ /** @type {number|null} Timer ID for reconnection */
36
+ var _bridgeReconnectTimer = null;
37
+
38
+ /** @type {number} Configured port (loaded from storage, default 8765) */
39
+ var _bridgePort = 8765;
40
+
41
+ /** @type {boolean} Whether auto-connect is enabled */
42
+ var _bridgeAutoConnect = true;
43
+
44
+ /** @type {Map<string, {resolve: Function, reject: Function, timer: number}>} */
45
+ var _bridgePendingRequests = new Map();
46
+
47
+ /** @type {Function[]} Callbacks fired on connection status changes */
48
+ var _bridgeStatusCallbacks = [];
49
+
50
+ /** @type {Function[]} Callbacks fired when a script-save message arrives */
51
+ var _scriptSaveCallbacks = [];
52
+
53
+ /** @type {number} Request timeout in milliseconds */
54
+ var BRIDGE_REQUEST_TIMEOUT = 30000;
55
+
56
+ /** @type {number} Max fast reconnect attempts before backing off */
57
+ var BRIDGE_MAX_FAST_RECONNECTS = 3;
58
+
59
+ /** @type {number} Fast reconnect interval in milliseconds */
60
+ var BRIDGE_RECONNECT_FAST = 10000;
61
+
62
+ /** @type {number} Slow (backoff) reconnect interval in milliseconds */
63
+ var BRIDGE_RECONNECT_SLOW = 30000;
64
+
65
+
66
+ // ─── ID Generation ─────────────────────────────────────────────
67
+
68
+ /**
69
+ * Generate a unique request ID for correlating request/response pairs.
70
+ * @returns {string}
71
+ */
72
+ function _bridgeGenerateId() {
73
+ return Date.now() + '-' + Math.random().toString(36).substr(2, 9);
74
+ }
75
+
76
+
77
+ // ─── Status Change Notifications ───────────────────────────────
78
+
79
+ /**
80
+ * Fire all registered status change callbacks with the current state.
81
+ */
82
+ function _bridgeFireStatusChange() {
83
+ var status = getBridgeStatus();
84
+ for (var i = 0; i < _bridgeStatusCallbacks.length; i++) {
85
+ try {
86
+ _bridgeStatusCallbacks[i](status);
87
+ } catch (e) {
88
+ // Don't let a bad callback break the bridge
89
+ }
90
+ }
91
+ }
92
+
93
+
94
+ // ─── Connection Management ─────────────────────────────────────
95
+
96
+ /**
97
+ * Initialize the integration bridge.
98
+ *
99
+ * Loads configuration from chrome.storage and optionally auto-connects
100
+ * to the NowAIKit Builder VS Code extension WebSocket server.
101
+ *
102
+ * @param {Object} [options]
103
+ * @param {number} [options.port] - WebSocket port (default: from storage or 8765)
104
+ * @param {boolean} [options.autoConnect] - Connect immediately (default: from storage or true)
105
+ * @param {Function} [options.onStatusChange] - Callback for connection status changes
106
+ */
107
+ function initIntegrationBridge(options) {
108
+ options = options || {};
109
+
110
+ // Register status change callback if provided
111
+ if (typeof options.onStatusChange === 'function') {
112
+ _bridgeStatusCallbacks.push(options.onStatusChange);
113
+ }
114
+
115
+ // Load configuration from chrome.storage, then connect if enabled
116
+ chrome.storage.local.get({ bridgePort: 8765 }, function(localData) {
117
+ _bridgePort = options.port || localData.bridgePort || 8765;
118
+
119
+ chrome.storage.sync.get({ enableBridgeAutoConnect: true }, function(syncData) {
120
+ _bridgeAutoConnect = (options.autoConnect !== undefined)
121
+ ? options.autoConnect
122
+ : syncData.enableBridgeAutoConnect;
123
+
124
+ if (_bridgeAutoConnect) {
125
+ connectToBridge();
126
+ }
127
+ });
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Connect (or reconnect) to the NowAIKit Builder WebSocket server.
133
+ *
134
+ * Safe to call multiple times — if already connected, this is a no-op.
135
+ * On successful connection, immediately queries the Builder for instance info.
136
+ */
137
+ function connectToBridge() {
138
+ // Don't open a second socket
139
+ if (_bridgeSocket && (_bridgeSocket.readyState === WebSocket.CONNECTING || _bridgeSocket.readyState === WebSocket.OPEN)) {
140
+ return;
141
+ }
142
+
143
+ // Clear any pending reconnect timer
144
+ if (_bridgeReconnectTimer !== null) {
145
+ clearTimeout(_bridgeReconnectTimer);
146
+ _bridgeReconnectTimer = null;
147
+ }
148
+
149
+ var url = 'ws://localhost:' + _bridgePort;
150
+
151
+ try {
152
+ _bridgeSocket = new WebSocket(url);
153
+ } catch (e) {
154
+ // WebSocket constructor can throw if URL is invalid or localhost blocked
155
+ _bridgeConnected = false;
156
+ _bridgeFireStatusChange();
157
+ _bridgeScheduleReconnect();
158
+ return;
159
+ }
160
+
161
+ // ── onopen ──
162
+
163
+ _bridgeSocket.onopen = function() {
164
+ _bridgeConnected = true;
165
+ _bridgeReconnectAttempts = 0;
166
+ _bridgeFireStatusChange();
167
+
168
+ // Immediately fetch instance info from Builder
169
+ _bridgeRequest('get_instance_info', {}).then(function(result) {
170
+ if (result) {
171
+ _bridgeInstanceName = result.name || result.instanceName || '';
172
+ _bridgeInstanceUrl = result.url || result.instanceUrl || '';
173
+ _bridgeFireStatusChange();
174
+ }
175
+ }).catch(function() {
176
+ // Non-fatal — Builder might not have instance info yet
177
+ });
178
+ };
179
+
180
+ // ── onmessage ──
181
+
182
+ _bridgeSocket.onmessage = function(event) {
183
+ var msg;
184
+ try {
185
+ msg = JSON.parse(event.data);
186
+ } catch (e) {
187
+ return; // Ignore non-JSON messages
188
+ }
189
+
190
+ if (msg.type === 'api-response') {
191
+ _bridgeHandleResponse(msg);
192
+ } else if (msg.type === 'script-save') {
193
+ _bridgeHandleScriptSave(msg);
194
+ }
195
+ };
196
+
197
+ // ── onerror ──
198
+
199
+ _bridgeSocket.onerror = function() {
200
+ // Error usually followed by close — actual handling happens in onclose
201
+ };
202
+
203
+ // ── onclose ──
204
+
205
+ _bridgeSocket.onclose = function() {
206
+ var wasConnected = _bridgeConnected;
207
+ _bridgeConnected = false;
208
+ _bridgeInstanceName = '';
209
+ _bridgeInstanceUrl = '';
210
+ _bridgeSocket = null;
211
+
212
+ // Reject all pending requests
213
+ _bridgePendingRequests.forEach(function(entry) {
214
+ clearTimeout(entry.timer);
215
+ entry.reject(new Error('Bridge connection closed'));
216
+ });
217
+ _bridgePendingRequests.clear();
218
+
219
+ if (wasConnected) {
220
+ _bridgeFireStatusChange();
221
+ }
222
+
223
+ _bridgeScheduleReconnect();
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Manually disconnect from the Builder WebSocket server.
229
+ *
230
+ * Cancels any pending reconnect timer so the bridge stays disconnected
231
+ * until `connectToBridge()` is called again.
232
+ */
233
+ function disconnectBridge() {
234
+ // Cancel reconnect
235
+ if (_bridgeReconnectTimer !== null) {
236
+ clearTimeout(_bridgeReconnectTimer);
237
+ _bridgeReconnectTimer = null;
238
+ }
239
+
240
+ _bridgeReconnectAttempts = 0;
241
+
242
+ if (_bridgeSocket) {
243
+ // Prevent onclose from triggering reconnect
244
+ _bridgeSocket.onclose = null;
245
+ _bridgeSocket.onerror = null;
246
+ _bridgeSocket.close();
247
+ _bridgeSocket = null;
248
+ }
249
+
250
+ var wasConnected = _bridgeConnected;
251
+ _bridgeConnected = false;
252
+ _bridgeInstanceName = '';
253
+ _bridgeInstanceUrl = '';
254
+
255
+ // Reject pending requests
256
+ _bridgePendingRequests.forEach(function(entry) {
257
+ clearTimeout(entry.timer);
258
+ entry.reject(new Error('Bridge manually disconnected'));
259
+ });
260
+ _bridgePendingRequests.clear();
261
+
262
+ if (wasConnected) {
263
+ _bridgeFireStatusChange();
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Get the current bridge connection status.
269
+ *
270
+ * @returns {{ connected: boolean, instanceName: string, instanceUrl: string }}
271
+ */
272
+ function getBridgeStatus() {
273
+ return {
274
+ connected: _bridgeConnected,
275
+ instanceName: _bridgeInstanceName,
276
+ instanceUrl: _bridgeInstanceUrl,
277
+ };
278
+ }
279
+
280
+
281
+ // ─── Reconnection Logic ────────────────────────────────────────
282
+
283
+ /**
284
+ * Schedule a reconnection attempt with exponential backoff.
285
+ *
286
+ * First 3 attempts use a 10-second interval, then backs off to 30 seconds.
287
+ */
288
+ function _bridgeScheduleReconnect() {
289
+ if (_bridgeReconnectTimer !== null) return; // Already scheduled
290
+
291
+ _bridgeReconnectAttempts++;
292
+
293
+ var delay = (_bridgeReconnectAttempts <= BRIDGE_MAX_FAST_RECONNECTS)
294
+ ? BRIDGE_RECONNECT_FAST
295
+ : BRIDGE_RECONNECT_SLOW;
296
+
297
+ _bridgeReconnectTimer = setTimeout(function() {
298
+ _bridgeReconnectTimer = null;
299
+ connectToBridge();
300
+ }, delay);
301
+ }
302
+
303
+
304
+ // ─── Request / Response Correlation ────────────────────────────
305
+
306
+ /**
307
+ * Handle an incoming api-response message from the Builder.
308
+ *
309
+ * Matches the response to a pending request by ID, resolves or rejects
310
+ * the associated Promise, and cleans up the timeout timer.
311
+ *
312
+ * @param {{ id: string, success: boolean, result?: *, error?: string }} msg
313
+ */
314
+ function _bridgeHandleResponse(msg) {
315
+ var id = msg.id;
316
+ if (!id || !_bridgePendingRequests.has(id)) return;
317
+
318
+ var entry = _bridgePendingRequests.get(id);
319
+ _bridgePendingRequests.delete(id);
320
+ clearTimeout(entry.timer);
321
+
322
+ if (msg.success) {
323
+ entry.resolve(msg.result);
324
+ } else {
325
+ entry.reject(new Error(msg.error || 'Unknown bridge error'));
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Send an API request to the Builder and return a Promise for the response.
331
+ *
332
+ * @param {string} method - InstanceService method name
333
+ * @param {Object} params - Method parameters
334
+ * @returns {Promise<*>} - Resolves with the result payload
335
+ */
336
+ function _bridgeRequest(method, params) {
337
+ return new Promise(function(resolve, reject) {
338
+ if (!_bridgeSocket || _bridgeSocket.readyState !== WebSocket.OPEN) {
339
+ reject(new Error('Bridge not connected'));
340
+ return;
341
+ }
342
+
343
+ var id = _bridgeGenerateId();
344
+
345
+ var timer = setTimeout(function() {
346
+ if (_bridgePendingRequests.has(id)) {
347
+ _bridgePendingRequests.delete(id);
348
+ reject(new Error('Bridge request timed out (' + method + ')'));
349
+ }
350
+ }, BRIDGE_REQUEST_TIMEOUT);
351
+
352
+ _bridgePendingRequests.set(id, {
353
+ resolve: resolve,
354
+ reject: reject,
355
+ timer: timer,
356
+ });
357
+
358
+ var message = {
359
+ type: 'api-request',
360
+ id: id,
361
+ method: method,
362
+ params: params || {},
363
+ };
364
+
365
+ try {
366
+ _bridgeSocket.send(JSON.stringify(message));
367
+ } catch (e) {
368
+ _bridgePendingRequests.delete(id);
369
+ clearTimeout(timer);
370
+ reject(new Error('Failed to send bridge request: ' + e.message));
371
+ }
372
+ });
373
+ }
374
+
375
+
376
+ // ─── Script Sync Handling ──────────────────────────────────────
377
+
378
+ /**
379
+ * Handle an incoming script-save message from the Builder.
380
+ *
381
+ * Attempts to update the corresponding form field in the ServiceNow page
382
+ * if we are viewing the matching record, then notifies all registered
383
+ * script-save callbacks.
384
+ *
385
+ * @param {{ table: string, sys_id: string, field: string, name: string, scope: string, content: string }} msg
386
+ */
387
+ function _bridgeHandleScriptSave(msg) {
388
+ // Try to update the ServiceNow form if we're on the right page
389
+ _bridgeApplyScriptToPage(msg);
390
+
391
+ // Fire registered callbacks
392
+ for (var i = 0; i < _scriptSaveCallbacks.length; i++) {
393
+ try {
394
+ _scriptSaveCallbacks[i](msg);
395
+ } catch (e) {
396
+ // Don't let a bad callback break the handler
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Attempt to apply script content to the current ServiceNow page.
403
+ *
404
+ * Checks if we are on a form for the matching table/sys_id, then updates
405
+ * the field via g_form.setValue(), CodeMirror, or textarea fallback.
406
+ *
407
+ * @param {{ table: string, sys_id: string, field: string, content: string }} msg
408
+ */
409
+ function _bridgeApplyScriptToPage(msg) {
410
+ // Check if we're on the matching record page
411
+ // currentTable and currentSysId are defined in content.js
412
+ if (typeof currentTable === 'undefined' || typeof currentSysId === 'undefined') return;
413
+ if (!msg.table || !msg.sys_id || !msg.field || typeof msg.content !== 'string') return;
414
+ if (msg.table !== currentTable || msg.sys_id !== currentSysId) return;
415
+
416
+ // Validate field name is a safe identifier (prevent CSS selector injection)
417
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(msg.field)) return;
418
+
419
+ // Method 1: Use g_form.setValue() if available (most reliable)
420
+ if (typeof g_form !== 'undefined' && typeof g_form.setValue === 'function') {
421
+ try {
422
+ g_form.setValue(msg.field, msg.content);
423
+ return;
424
+ } catch (e) {
425
+ // Fall through to other methods
426
+ }
427
+ }
428
+
429
+ // Method 2: Find a CodeMirror editor for the field
430
+ var fieldElement = document.getElementById(msg.field) || document.querySelector('[name="' + msg.field + '"]');
431
+ if (fieldElement) {
432
+ // Check if the field has a CodeMirror instance
433
+ var cmWrapper = fieldElement.closest('.CodeMirror') || (fieldElement.nextElementSibling && fieldElement.nextElementSibling.classList.contains('CodeMirror') ? fieldElement.nextElementSibling : null);
434
+ if (!cmWrapper) {
435
+ // Some ServiceNow forms wrap the textarea, and the CM is a sibling
436
+ var parent = fieldElement.parentElement;
437
+ if (parent) {
438
+ cmWrapper = parent.querySelector('.CodeMirror');
439
+ }
440
+ }
441
+
442
+ if (cmWrapper && cmWrapper.CodeMirror) {
443
+ try {
444
+ cmWrapper.CodeMirror.setValue(msg.content);
445
+ return;
446
+ } catch (e) {
447
+ // Fall through
448
+ }
449
+ }
450
+
451
+ // Method 3: Direct textarea update
452
+ if (fieldElement.tagName === 'TEXTAREA' || fieldElement.tagName === 'INPUT') {
453
+ fieldElement.value = msg.content;
454
+ fieldElement.dispatchEvent(new Event('input', { bubbles: true }));
455
+ fieldElement.dispatchEvent(new Event('change', { bubbles: true }));
456
+ return;
457
+ }
458
+ }
459
+ }
460
+
461
+
462
+ // ─── Public API: ServiceNow Instance Queries ───────────────────
463
+
464
+ /**
465
+ * Query records from a ServiceNow table via the Builder's InstanceService.
466
+ *
467
+ * @param {string} table - Table name (e.g. 'incident', 'sys_script')
468
+ * @param {string} [query] - Encoded query string (e.g. 'active=true^priority=1')
469
+ * @param {string[]} [fields] - Array of field names to return
470
+ * @param {number} [limit] - Maximum records to return
471
+ * @returns {Promise<Object[]>}
472
+ */
473
+ function bridgeQueryRecords(table, query, fields, limit) {
474
+ var params = { table: table };
475
+ if (query) params.query = query;
476
+ if (fields) params.fields = fields;
477
+ if (limit) params.limit = limit;
478
+ return _bridgeRequest('query_records', params);
479
+ }
480
+
481
+ /**
482
+ * Get a single record by sys_id via the Builder's InstanceService.
483
+ *
484
+ * @param {string} table - Table name
485
+ * @param {string} sysId - Record sys_id
486
+ * @param {string[]} [fields] - Array of field names to return
487
+ * @returns {Promise<Object>}
488
+ */
489
+ function bridgeGetRecord(table, sysId, fields) {
490
+ var params = { table: table, sys_id: sysId };
491
+ if (fields) params.fields = fields;
492
+ return _bridgeRequest('get_record', params);
493
+ }
494
+
495
+ /**
496
+ * Get the schema (field definitions) for a ServiceNow table.
497
+ *
498
+ * @param {string} table - Table name
499
+ * @returns {Promise<Object>}
500
+ */
501
+ function bridgeGetTableSchema(table) {
502
+ return _bridgeRequest('get_table_schema', { table: table });
503
+ }
504
+
505
+ /**
506
+ * Get update sets, optionally filtered by state.
507
+ *
508
+ * @param {string} [state] - Filter by state (e.g. 'in progress', 'complete')
509
+ * @returns {Promise<Object[]>}
510
+ */
511
+ function bridgeGetUpdateSets(state) {
512
+ var params = {};
513
+ if (state) params.state = state;
514
+ return _bridgeRequest('get_update_sets', params);
515
+ }
516
+
517
+ /**
518
+ * Get scripts from the instance, filtered by table type and scope.
519
+ *
520
+ * @param {string} [tableType] - Script table (e.g. 'sys_script', 'sys_script_include')
521
+ * @param {string} [scope] - Application scope
522
+ * @returns {Promise<Object[]>}
523
+ */
524
+ function bridgeGetScripts(tableType, scope) {
525
+ var params = {};
526
+ if (tableType) params.table_type = tableType;
527
+ if (scope) params.scope = scope;
528
+ return _bridgeRequest('get_scripts', params);
529
+ }
530
+
531
+ /**
532
+ * Execute a server-side script on the instance (background script).
533
+ *
534
+ * @param {string} script - Script body to execute
535
+ * @param {string} [scope] - Application scope to run in
536
+ * @returns {Promise<Object>}
537
+ */
538
+ function bridgeExecuteScript(script, scope) {
539
+ var params = { script: script };
540
+ if (scope) params.scope = scope;
541
+ return _bridgeRequest('execute_script', params);
542
+ }
543
+
544
+ /**
545
+ * Get instance health information (node status, stats, etc.).
546
+ *
547
+ * @returns {Promise<Object>}
548
+ */
549
+ function bridgeGetInstanceHealth() {
550
+ return _bridgeRequest('get_instance_health', {});
551
+ }
552
+
553
+ /**
554
+ * Get instance identification info (name, URL, version, etc.).
555
+ *
556
+ * @returns {Promise<Object>}
557
+ */
558
+ function bridgeGetInstanceInfo() {
559
+ return _bridgeRequest('get_instance_info', {});
560
+ }
561
+
562
+ /**
563
+ * Search for scripts matching a query across one or more script tables.
564
+ *
565
+ * @param {string} [table] - Script table to search (e.g. 'sys_script_include')
566
+ * @param {string} [query] - Search text or encoded query
567
+ * @param {string[]} [fields] - Fields to return
568
+ * @param {number} [limit] - Maximum results
569
+ * @returns {Promise<Object[]>}
570
+ */
571
+ function bridgeSearchScripts(table, query, fields, limit) {
572
+ var params = {};
573
+ if (table) params.table = table;
574
+ if (query) params.query = query;
575
+ if (fields) params.fields = fields;
576
+ if (limit) params.limit = limit;
577
+ return _bridgeRequest('search_scripts', params);
578
+ }
579
+
580
+
581
+ // ─── Public API: Script Sync ───────────────────────────────────
582
+
583
+ /**
584
+ * Send a script-change message to the Builder, notifying it that
585
+ * a script has been modified in the browser.
586
+ *
587
+ * @param {string} table - Table name (e.g. 'sys_script_include')
588
+ * @param {string} sysId - Record sys_id
589
+ * @param {string} field - Field name (e.g. 'script')
590
+ * @param {string} name - Record display name
591
+ * @param {string} scope - Application scope
592
+ * @param {string} content - Updated script content
593
+ */
594
+ function bridgeSendScriptChange(table, sysId, field, name, scope, content) {
595
+ if (!_bridgeSocket || _bridgeSocket.readyState !== WebSocket.OPEN) {
596
+ return; // Silently skip if not connected
597
+ }
598
+
599
+ var message = {
600
+ type: 'script-change',
601
+ table: table,
602
+ sys_id: sysId,
603
+ field: field,
604
+ name: name,
605
+ scope: scope,
606
+ content: content,
607
+ };
608
+
609
+ try {
610
+ _bridgeSocket.send(JSON.stringify(message));
611
+ } catch (e) {
612
+ // Silently fail — connection may have just dropped
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Register a callback to be invoked when a script-save message arrives
618
+ * from the Builder (indicating a script was saved in VS Code).
619
+ *
620
+ * @param {Function} callback - Called with the script-save message object:
621
+ * { table, sys_id, field, name, scope, content }
622
+ */
623
+ function onBridgeScriptSave(callback) {
624
+ if (typeof callback === 'function') {
625
+ _scriptSaveCallbacks.push(callback);
626
+ }
627
+ }