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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sso-sap-client.mjs +76 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abap-local-client",
3
- "version": "1.4.16",
3
+ "version": "1.4.17",
4
4
  "description": "SAP Local Client — WebSocket bridge with Basic, SPNEGO, X.509, SAML & SNC authentication",
5
5
  "main": "sso-sap-client.mjs",
6
6
  "type": "module",
@@ -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
- if (error.response?.status === 401 || error.response?.status === 403) {
580
- console.error(` ⚠️ Session error (${error.response.status}) - clearing...`);
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() { this.csrfToken = null; this.cookies.clear(); console.log(' 🧹 Session cleared'); }
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
- console.log(` Reconnecting in 5s...`);
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(); }, 5000);
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
  }