abap-local-client 1.4.16 → 1.4.17
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/package.json +1 -1
- package/sso-sap-client.mjs +76 -10
package/package.json
CHANGED
package/sso-sap-client.mjs
CHANGED
|
@@ -517,10 +517,11 @@ class LockManager {
|
|
|
517
517
|
// ════════════════════════════════════════════════════════════════════════════
|
|
518
518
|
|
|
519
519
|
class SAPClient {
|
|
520
|
-
constructor(sessionEntry) {
|
|
520
|
+
constructor(sessionEntry, onSessionCleared) {
|
|
521
521
|
this.session = sessionEntry;
|
|
522
522
|
this.csrfToken = null;
|
|
523
523
|
this.cookies = new Map();
|
|
524
|
+
this.onSessionCleared = onSessionCleared || null;
|
|
524
525
|
|
|
525
526
|
const conn = sessionEntry.sysConfig?.connection || {};
|
|
526
527
|
const baseUrl = `https://${conn.host}:${conn.port || '44301'}`;
|
|
@@ -576,8 +577,20 @@ class SAPClient {
|
|
|
576
577
|
return response;
|
|
577
578
|
},
|
|
578
579
|
error => {
|
|
579
|
-
|
|
580
|
-
|
|
580
|
+
// NOTE: only 401 (true auth/session failure) wipes the session here.
|
|
581
|
+
// 403 is deliberately NOT treated as "session invalid" — SAP ADT also
|
|
582
|
+
// returns 403 for business-logic errors that have nothing to do with
|
|
583
|
+
// the HTTP session (e.g. "object locked by another user", missing
|
|
584
|
+
// authorization for a specific object, or "CSRF token required").
|
|
585
|
+
// Blindly clearing cookies/CSRF on every 403 used to nuke a perfectly
|
|
586
|
+
// valid stateful session in the middle of a lock/save/activate flow,
|
|
587
|
+
// orphaning any lock the client held (SAP kept the enqueue tied to the
|
|
588
|
+
// now-discarded session/cookie, while the client silently opened a
|
|
589
|
+
// brand-new anonymous session for the next call). CSRF-required 403s
|
|
590
|
+
// are instead handled locally with a scoped refresh+retry in
|
|
591
|
+
// _executeCallspecViaHTTP.
|
|
592
|
+
if (error.response?.status === 401) {
|
|
593
|
+
console.error(` ⚠️ Session error (401) - clearing...`);
|
|
581
594
|
this.clearSession();
|
|
582
595
|
}
|
|
583
596
|
return Promise.reject(error);
|
|
@@ -677,7 +690,7 @@ class SAPClient {
|
|
|
677
690
|
}
|
|
678
691
|
}
|
|
679
692
|
|
|
680
|
-
async _executeCallspecViaHTTP(callspec) {
|
|
693
|
+
async _executeCallspecViaHTTP(callspec, isCsrfRetry = false) {
|
|
681
694
|
const { method, url, headers, extract_from_response } = callspec;
|
|
682
695
|
let finalUrl = url;
|
|
683
696
|
let finalHeaders = { ...headers };
|
|
@@ -768,6 +781,22 @@ class SAPClient {
|
|
|
768
781
|
return { success: !hasErrors && response.status >= 200 && response.status < 300, status: response.status, statusText: response.statusText, headers: response.headers, data: { raw: response.data, ...extractedData }, ...(errorMessages.length > 0 && { error: `SAP validation failed: ${errorMessages.join('; ')}`, errorDetails: errorMessages }) };
|
|
769
782
|
} catch (error) {
|
|
770
783
|
const status = error.response?.status;
|
|
784
|
+
|
|
785
|
+
// ── CSRF-required retry (scoped, one-shot) ──────────────────────────
|
|
786
|
+
// SAP responds 403 with `x-csrf-token: Required` when the cached token
|
|
787
|
+
// is stale/missing — this is NOT a session-invalidating error, it just
|
|
788
|
+
// means we need a fresh token. Refresh it and retry this one request
|
|
789
|
+
// exactly once, instead of relying on the (now-removed) blanket
|
|
790
|
+
// session wipe that used to happen for every 403 in the interceptor.
|
|
791
|
+
const csrfHeader = (error.response?.headers?.['x-csrf-token'] || '').toLowerCase();
|
|
792
|
+
if (status === 403 && csrfHeader === 'required' && !isCsrfRetry) {
|
|
793
|
+
console.warn(' 🔄 CSRF token rejected (Required) — refreshing token and retrying once...');
|
|
794
|
+
this.csrfToken = null;
|
|
795
|
+
await this.fetchCsrfToken();
|
|
796
|
+
if (this.csrfToken) return this._executeCallspecViaHTTP(callspec, true);
|
|
797
|
+
console.error(' ❌ CSRF refresh failed — no token obtained, giving up on retry');
|
|
798
|
+
}
|
|
799
|
+
|
|
771
800
|
console.error(` ❌ HTTP ${status || 'network error'}: ${error.message}`);
|
|
772
801
|
if (error.response?.headers) {
|
|
773
802
|
console.error(` ❌ RESPONSE HEADERS:`);
|
|
@@ -803,7 +832,17 @@ class SAPClient {
|
|
|
803
832
|
catch (error) { if (error.response?.headers['x-csrf-token']) this.csrfToken = error.response.headers['x-csrf-token']; }
|
|
804
833
|
}
|
|
805
834
|
|
|
806
|
-
clearSession() {
|
|
835
|
+
clearSession() {
|
|
836
|
+
this.csrfToken = null;
|
|
837
|
+
this.cookies.clear();
|
|
838
|
+
console.log(' 🧹 Session cleared');
|
|
839
|
+
// The stateful ADT session that owned any cached locks is gone — a lock
|
|
840
|
+
// handle cached against it is now orphaned, so tell the caller to drop it
|
|
841
|
+
// too (mirrors the RFC-side stale-lock cleanup on 423).
|
|
842
|
+
if (this.onSessionCleared) {
|
|
843
|
+
try { this.onSessionCleared(); } catch { /* best-effort */ }
|
|
844
|
+
}
|
|
845
|
+
}
|
|
807
846
|
|
|
808
847
|
async ping() {
|
|
809
848
|
try { const r = await this.client.get('/sap/bc/adt/compatibility/graph', { timeout: 5000 }); return { success: true, status: r.status }; }
|
|
@@ -821,6 +860,8 @@ class WebSocketClient {
|
|
|
821
860
|
this.sapClient = null;
|
|
822
861
|
this.reconnectTimer = null;
|
|
823
862
|
this.keepAliveTimer = null;
|
|
863
|
+
this.wsPingTimer = null;
|
|
864
|
+
this.isAlive = false;
|
|
824
865
|
this.isReconnecting = false;
|
|
825
866
|
this.lockManager = new LockManager();
|
|
826
867
|
this.currentSystemId = null;
|
|
@@ -831,7 +872,10 @@ class WebSocketClient {
|
|
|
831
872
|
if (!sid) { console.error('❌ No system selected'); return false; }
|
|
832
873
|
try {
|
|
833
874
|
const sessionEntry = await getSessionForSystem(sid);
|
|
834
|
-
this.sapClient = new SAPClient(sessionEntry)
|
|
875
|
+
this.sapClient = new SAPClient(sessionEntry, () => {
|
|
876
|
+
this.lockManager.clearAll();
|
|
877
|
+
console.log(' 🗑️ Lock cache cleared (HTTP session was reset — cached locks are now orphaned)');
|
|
878
|
+
});
|
|
835
879
|
console.log(`✅ SAPClient initialized for ${sid}`);
|
|
836
880
|
return true;
|
|
837
881
|
} catch (err) {
|
|
@@ -849,10 +893,12 @@ class WebSocketClient {
|
|
|
849
893
|
console.log(`\n🔌 Connecting to MCP Cloud...`);
|
|
850
894
|
console.log(` URL: ${WS_URL}`);
|
|
851
895
|
this.ws = new WebSocket(WS_URL, { headers: { 'Authorization': `Bearer ${MCP_API_KEY}` } });
|
|
896
|
+
this.isAlive = true;
|
|
852
897
|
this.ws.on('open', () => this.onOpen());
|
|
853
898
|
this.ws.on('message', (data) => this.onMessage(data));
|
|
854
899
|
this.ws.on('error', (error) => this.onError(error));
|
|
855
900
|
this.ws.on('close', () => this.onClose());
|
|
901
|
+
this.ws.on('pong', () => { this.isAlive = true; });
|
|
856
902
|
}
|
|
857
903
|
|
|
858
904
|
onOpen() {
|
|
@@ -860,6 +906,7 @@ class WebSocketClient {
|
|
|
860
906
|
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
861
907
|
this.isReconnecting = false;
|
|
862
908
|
this.startKeepAlive();
|
|
909
|
+
this.startWsPing();
|
|
863
910
|
}
|
|
864
911
|
|
|
865
912
|
startKeepAlive() {
|
|
@@ -872,6 +919,24 @@ class WebSocketClient {
|
|
|
872
919
|
|
|
873
920
|
stopKeepAlive() { if (this.keepAliveTimer) { clearInterval(this.keepAliveTimer); this.keepAliveTimer = null; } }
|
|
874
921
|
|
|
922
|
+
// WebSocket-frame-level heartbeat (independent of the SAP keepalive above).
|
|
923
|
+
// Tunnels/proxies (e.g. ngrok) silently drop idle connections that have no
|
|
924
|
+
// traffic for a while. Sending a ping every WS_PING_INTERVAL ms keeps the
|
|
925
|
+
// tunnel alive and — if a pong doesn't come back in time — proactively
|
|
926
|
+
// terminates the dead socket so reconnection kicks in immediately instead
|
|
927
|
+
// of waiting for the OS to notice the broken connection.
|
|
928
|
+
startWsPing() {
|
|
929
|
+
if (this.wsPingTimer) clearInterval(this.wsPingTimer);
|
|
930
|
+
this.wsPingTimer = setInterval(() => {
|
|
931
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
932
|
+
if (!this.isAlive) { console.log(' 💔 No pong received — terminating stale connection'); this.ws.terminate(); return; }
|
|
933
|
+
this.isAlive = false;
|
|
934
|
+
this.ws.ping();
|
|
935
|
+
}, parseInt(env.WS_PING_INTERVAL || '20000', 10));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
stopWsPing() { if (this.wsPingTimer) { clearInterval(this.wsPingTimer); this.wsPingTimer = null; } }
|
|
939
|
+
|
|
875
940
|
async onMessage(data) {
|
|
876
941
|
let message;
|
|
877
942
|
try { message = JSON.parse(data.toString()); } catch { return; }
|
|
@@ -1147,19 +1212,20 @@ class WebSocketClient {
|
|
|
1147
1212
|
|
|
1148
1213
|
onClose() {
|
|
1149
1214
|
console.log(`\n⚠️ Disconnected`);
|
|
1150
|
-
this.ws = null; this.stopKeepAlive();
|
|
1215
|
+
this.ws = null; this.stopKeepAlive(); this.stopWsPing();
|
|
1151
1216
|
if (this.sapClient) this.sapClient.clearSession();
|
|
1152
1217
|
this.lockManager.clearAll();
|
|
1153
1218
|
if (!this.isReconnecting && !this.reconnectTimer) {
|
|
1154
|
-
|
|
1219
|
+
const delayMs = parseInt(env.WS_RECONNECT_DELAY || '2000', 10);
|
|
1220
|
+
console.log(` Reconnecting in ${(delayMs / 1000).toFixed(1)}s...`);
|
|
1155
1221
|
this.isReconnecting = true;
|
|
1156
|
-
this.reconnectTimer = setTimeout(() => { this.isReconnecting = false; this.connect(); },
|
|
1222
|
+
this.reconnectTimer = setTimeout(() => { this.isReconnecting = false; this.connect(); }, delayMs);
|
|
1157
1223
|
}
|
|
1158
1224
|
}
|
|
1159
1225
|
|
|
1160
1226
|
disconnect() {
|
|
1161
1227
|
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
1162
|
-
this.isReconnecting = false; this.stopKeepAlive();
|
|
1228
|
+
this.isReconnecting = false; this.stopKeepAlive(); this.stopWsPing();
|
|
1163
1229
|
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
1164
1230
|
}
|
|
1165
1231
|
}
|