@mcp-abap-adt/connection 4.0.0 → 5.0.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.
- package/CHANGELOG.md +274 -1
- package/README.md +60 -30
- package/dist/auth/IAuthProvider.d.ts +84 -0
- package/dist/auth/IAuthProvider.d.ts.map +1 -0
- package/dist/auth/IAuthProvider.js +21 -0
- package/dist/auth/providers.d.ts +95 -0
- package/dist/auth/providers.d.ts.map +1 -0
- package/dist/auth/providers.js +140 -0
- package/dist/connection/AbstractAbapConnection.d.ts +222 -17
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +555 -31
- package/dist/connection/AdtCloudConnector.d.ts +29 -0
- package/dist/connection/AdtCloudConnector.d.ts.map +1 -0
- package/dist/connection/AdtCloudConnector.js +31 -0
- package/dist/connection/AdtOnPremConnector.d.ts +30 -0
- package/dist/connection/AdtOnPremConnector.d.ts.map +1 -0
- package/dist/connection/AdtOnPremConnector.js +32 -0
- package/dist/connection/BaseAbapConnection.d.ts +7 -1
- package/dist/connection/BaseAbapConnection.d.ts.map +1 -1
- package/dist/connection/BaseAbapConnection.js +7 -1
- package/dist/connection/CertificateAbapConnection.d.ts +11 -1
- package/dist/connection/CertificateAbapConnection.d.ts.map +1 -1
- package/dist/connection/CertificateAbapConnection.js +13 -1
- package/dist/connection/CredentialAbapConnection.d.ts +88 -0
- package/dist/connection/CredentialAbapConnection.d.ts.map +1 -0
- package/dist/connection/CredentialAbapConnection.js +195 -0
- package/dist/connection/JwtAbapConnection.d.ts +23 -7
- package/dist/connection/JwtAbapConnection.d.ts.map +1 -1
- package/dist/connection/JwtAbapConnection.js +26 -8
- package/dist/connection/KerberosAbapConnection.d.ts +9 -1
- package/dist/connection/KerberosAbapConnection.d.ts.map +1 -1
- package/dist/connection/KerberosAbapConnection.js +9 -1
- package/dist/connection/RfcAbapConnection.d.ts +0 -5
- package/dist/connection/RfcAbapConnection.d.ts.map +1 -1
- package/dist/connection/RfcAbapConnection.js +0 -7
- package/dist/connection/SamlAbapConnection.d.ts +7 -1
- package/dist/connection/SamlAbapConnection.d.ts.map +1 -1
- package/dist/connection/SamlAbapConnection.js +7 -1
- package/dist/connection/connectionFactory.d.ts +16 -0
- package/dist/connection/connectionFactory.d.ts.map +1 -1
- package/dist/connection/connectionFactory.js +52 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/session/CloudSecuritySessionStrategy.d.ts +32 -0
- package/dist/session/CloudSecuritySessionStrategy.d.ts.map +1 -0
- package/dist/session/CloudSecuritySessionStrategy.js +135 -0
- package/dist/session/IcfSessionStrategy.d.ts +27 -0
- package/dist/session/IcfSessionStrategy.d.ts.map +1 -0
- package/dist/session/IcfSessionStrategy.js +62 -0
- package/dist/session/SessionLifecycle.d.ts +17 -2
- package/dist/session/SessionLifecycle.d.ts.map +1 -1
- package/dist/session/SessionLifecycle.js +17 -2
- package/dist/session/SessionStrategy.d.ts +86 -0
- package/dist/session/SessionStrategy.d.ts.map +1 -0
- package/dist/session/SessionStrategy.js +33 -0
- package/dist/utils/cookies.d.ts +12 -0
- package/dist/utils/cookies.d.ts.map +1 -0
- package/dist/utils/cookies.js +24 -0
- package/dist/utils/timeouts.d.ts +16 -0
- package/dist/utils/timeouts.d.ts.map +1 -1
- package/dist/utils/timeouts.js +19 -0
- package/docs/INSTALLATION.md +6 -2
- package/docs/MIGRATION-2.0.md +1 -1
- package/docs/MIGRATION-5.0.md +116 -0
- package/docs/STATEFUL_SESSION_GUIDE.md +77 -11
- package/docs/USAGE.md +92 -22
- package/docs/superpowers/specs/2026-08-21-platform-connectors.md +108 -0
- package/package.json +1 -1
|
@@ -38,14 +38,50 @@ const node_crypto_1 = require("node:crypto");
|
|
|
38
38
|
const node_https_1 = require("node:https");
|
|
39
39
|
const interfaces_1 = require("@mcp-abap-adt/interfaces");
|
|
40
40
|
const axios_1 = __importStar(require("axios"));
|
|
41
|
+
const IcfSessionStrategy_js_1 = require("../session/IcfSessionStrategy.js");
|
|
41
42
|
const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
|
|
43
|
+
const cookies_js_1 = require("../utils/cookies.js");
|
|
42
44
|
const timeouts_js_1 = require("../utils/timeouts.js");
|
|
43
45
|
const csrfConfig_js_1 = require("./csrfConfig.js");
|
|
46
|
+
/**
|
|
47
|
+
* The configured default release deadline, refused at construction if it is not
|
|
48
|
+
* a number. `parseInt` alone would not do: it answers `NaN` for `"abc"` and `5`
|
|
49
|
+
* for `"5s"`, and both used to travel all the way to a teardown — the first as
|
|
50
|
+
* a throw from every `disconnect()` in the process, the second as a silently
|
|
51
|
+
* wrong bound nobody asked for.
|
|
52
|
+
*/
|
|
53
|
+
function readReleaseDeadline() {
|
|
54
|
+
const raw = process.env.SAP_RELEASE_DEADLINE_MS;
|
|
55
|
+
if (raw === undefined || raw.trim() === '') {
|
|
56
|
+
return (0, timeouts_js_1.getReleaseDeadline)();
|
|
57
|
+
}
|
|
58
|
+
const value = Number(raw);
|
|
59
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
60
|
+
throw new TypeError(`SAP_RELEASE_DEADLINE_MS must be a finite, non-negative number of milliseconds, got ${JSON.stringify(raw)}`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
44
64
|
/**
|
|
45
65
|
* Declares the capabilities explicitly rather than satisfying them by accident.
|
|
46
66
|
* `AbapConnection` is the base contract every transport honours; these two are
|
|
47
67
|
* the HTTP session's own, and naming them means a signature that drifts from
|
|
48
68
|
* the published contract fails to compile here instead of at the consumer.
|
|
69
|
+
*
|
|
70
|
+
* **This gives the consumer instruments; it does not decide for it.** `connect()`
|
|
71
|
+
* opens one session and `disconnect()` closes it. How many connections to hold,
|
|
72
|
+
* how long to hold them and when to let go stays with the caller — there are no
|
|
73
|
+
* thresholds here, no pooling and no eviction, because none of that is knowable
|
|
74
|
+
* from inside a single connection. A session this one did not open is not its
|
|
75
|
+
* business: the session limit is per user and the pool is shared, so a SAP GUI
|
|
76
|
+
* logon of the same user sits in the same list.
|
|
77
|
+
*
|
|
78
|
+
* **Nothing the server decides is treated as something to count on.** Whether it
|
|
79
|
+
* issues a session cookie, whether it still holds a session it issued, how many
|
|
80
|
+
* it will tolerate, how fast it answers a logoff — all of that is its own and
|
|
81
|
+
* may differ by system and release. So each is observed and reported, never
|
|
82
|
+
* relied upon: the logoff is best effort under a bound the caller sets, a
|
|
83
|
+
* missing session cookie is a warning rather than a rule, and no code here
|
|
84
|
+
* counts sessions or predicts the next answer from the last one.
|
|
49
85
|
*/
|
|
50
86
|
class AbstractAbapConnection {
|
|
51
87
|
config;
|
|
@@ -76,9 +112,63 @@ class AbstractAbapConnection {
|
|
|
76
112
|
inCriticalSection = false;
|
|
77
113
|
/** Reference count for nested beginCriticalSection()/endCriticalSection() pairs. */
|
|
78
114
|
criticalSectionDepth = 0;
|
|
115
|
+
/** The default release deadline, validated once at construction. */
|
|
116
|
+
releaseDeadlineMs;
|
|
117
|
+
/**
|
|
118
|
+
* The logoff for the session this connection last held, while it is on its
|
|
119
|
+
* way. One, because a connection holds one session: `connect()` opens it and
|
|
120
|
+
* `disconnect()` closes it, and how many connections to run is the caller's
|
|
121
|
+
* business, not something to be tracked here.
|
|
122
|
+
*
|
|
123
|
+
* Carries the session id, not just the promise, so a release still in flight
|
|
124
|
+
* for an EARLIER session is recognised as not being this one's — reusing it
|
|
125
|
+
* was what left the second session of a reconnect never released at all.
|
|
126
|
+
*
|
|
127
|
+
* The id is the `SAP_SESSIONID` value — the ABAP session, the one locks are
|
|
128
|
+
* bound to — never the cookie header: that header also carries `sap-XSRF_*`,
|
|
129
|
+
* which rotates within one and the same session, so comparing headers made a
|
|
130
|
+
* session stop recognising itself after a token refresh.
|
|
131
|
+
*/
|
|
132
|
+
/**
|
|
133
|
+
* How this server opens and gives back a session, decided by asking it rather
|
|
134
|
+
* than by guessing which system it is. Set at establishment; until then the
|
|
135
|
+
* on-prem mechanism, which is the one that needs no resource.
|
|
136
|
+
*/
|
|
137
|
+
/**
|
|
138
|
+
* The application server this session lives on, as the server named it.
|
|
139
|
+
*
|
|
140
|
+
* A session belongs to ONE application server. On a multi-node system a
|
|
141
|
+
* request that lands on another gets another session — and a lock held on the
|
|
142
|
+
* first is then dead through nobody's fault and no inactivity. Eclipse pins
|
|
143
|
+
* itself with these headers; without them every request is a fresh throw of
|
|
144
|
+
* the dice.
|
|
145
|
+
*
|
|
146
|
+
* Learned from `sap-adt-saplb` on a response, sent back as `saplb`. Cleared
|
|
147
|
+
* with the rest of the session state: it names a server for a session that no
|
|
148
|
+
* longer exists.
|
|
149
|
+
*/
|
|
150
|
+
appServer = null;
|
|
151
|
+
/**
|
|
152
|
+
* Whether the preflight opened a session of its own.
|
|
153
|
+
*
|
|
154
|
+
* Distinct from "there are cookies": a failed establishment often leaves a
|
|
155
|
+
* cookie from the 401 that rejected it, and that is debris, not a session.
|
|
156
|
+
* Only a preflight answered with a session address opened one, and only that
|
|
157
|
+
* is worth saying goodbye to when establishment then fails.
|
|
158
|
+
*/
|
|
159
|
+
preflightOpenedSession = false;
|
|
160
|
+
sessionStrategy = new IcfSessionStrategy_js_1.IcfSessionStrategy(null);
|
|
161
|
+
pendingRelease = null;
|
|
79
162
|
constructor(config, logger, sessionId, options) {
|
|
80
163
|
this.config = config;
|
|
81
164
|
this.logger = logger;
|
|
165
|
+
// Read and checked HERE, once, because the only other place it could be
|
|
166
|
+
// checked is disconnect() — and disconnect() belongs in a `finally`, where
|
|
167
|
+
// throwing replaces the error that sent the caller there. A misconfigured
|
|
168
|
+
// environment is a startup fault: it is the same on every call, it is not
|
|
169
|
+
// the caller's argument, and it is worth refusing a connection over rather
|
|
170
|
+
// than discovering at teardown.
|
|
171
|
+
this.releaseDeadlineMs = readReleaseDeadline();
|
|
82
172
|
this.skipSessionType = options?.skipSessionType ?? false;
|
|
83
173
|
// Generate sessionId (used for sap-adt-connection-id header)
|
|
84
174
|
this.sessionId = sessionId || (0, node_crypto_1.randomUUID)();
|
|
@@ -174,6 +264,27 @@ class AbstractAbapConnection {
|
|
|
174
264
|
getConfig() {
|
|
175
265
|
return this.config;
|
|
176
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Gets the credential ready before anything is sent.
|
|
269
|
+
*
|
|
270
|
+
* A no-op for the auth types whose credential is already in hand — basic
|
|
271
|
+
* builds a header from the configuration, JWT carries a token it was given.
|
|
272
|
+
* It exists for the ones that have to fetch or load theirs, because the
|
|
273
|
+
* preflight now runs BEFORE `establishSession()` and needs a credential to
|
|
274
|
+
* go out with: a certificate connection reads its material there, and
|
|
275
|
+
* without this the preflight throws `certificate material not loaded` while
|
|
276
|
+
* assembling the transport — before a single request is made, on every
|
|
277
|
+
* system, cloud or on-prem.
|
|
278
|
+
*
|
|
279
|
+
* Must be idempotent: `establishSession()` may prepare the same credential
|
|
280
|
+
* again, and does.
|
|
281
|
+
*
|
|
282
|
+
* Kerberos deliberately does NOT implement it. Minting the SPNEGO token this
|
|
283
|
+
* early changes when the exchange happens, and that connection is not
|
|
284
|
+
* production-tested — its preflight fails the way it already did, is caught
|
|
285
|
+
* inside the strategy, and the connection falls back to ICF as before.
|
|
286
|
+
*/
|
|
287
|
+
async prepareCredential() { }
|
|
177
288
|
/**
|
|
178
289
|
* Establishes the session, once, under the lifecycle.
|
|
179
290
|
*
|
|
@@ -199,26 +310,194 @@ class AbstractAbapConnection {
|
|
|
199
310
|
/**
|
|
200
311
|
* Tears the session down. Never throws, and always settles.
|
|
201
312
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
313
|
+
* Tells the server the session is no longer needed, then clears the local
|
|
314
|
+
* state. **When the server actually reclaims it is the server's business** —
|
|
315
|
+
* possibly not until the next connect asks it for one — and nothing here
|
|
316
|
+
* waits for that or depends on it. What matters is that the session stops
|
|
317
|
+
* being counted against the user, which dropping the cookie alone does not
|
|
318
|
+
* achieve: the server keeps it until its own timeout, so a process that
|
|
319
|
+
* connects repeatedly leaves one behind every time. Measured on S/4HANA on-prem, 25 connects in a row: with the logoff,
|
|
320
|
+
* 24-25 of them were given a session; without it, 2. A connection that gets
|
|
321
|
+
* no session still answers `200` to a LOCK and hands back a handle the next
|
|
322
|
+
* request cannot use, so the leak surfaces as a half-written object rather
|
|
323
|
+
* than as anything about sessions.
|
|
206
324
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
325
|
+
* **The logoff is the only thing waited for**, under `deadlineMs`, and
|
|
326
|
+
* deciding that bound is the caller's — see the parameter. Nothing else is
|
|
327
|
+
* waited for: finishing chains and releasing locks stay the caller's to do
|
|
328
|
+
* before calling, and waiting here on a request whose caller chose no timeout
|
|
329
|
+
* is what made a teardown unbounded, which blocks every later transition on
|
|
330
|
+
* the serialized tail.
|
|
210
331
|
*
|
|
211
|
-
*
|
|
332
|
+
* **Requests already in flight are not waited for, and the logoff ends the
|
|
333
|
+
* session they are running on** — so they will start failing against a
|
|
334
|
+
* session that no longer exists. That is the caller having asked to
|
|
335
|
+
* disconnect, not a race, and it is a change from the version that only
|
|
336
|
+
* dropped the cookie. Generation fencing (see `SessionLifecycle.isCurrent`)
|
|
337
|
+
* keeps their results from reaching this connection either way.
|
|
338
|
+
*
|
|
339
|
+
* @param options.deadlineMs How long to spend telling the server, measured
|
|
340
|
+
* from this call and including time spent queued behind another transition.
|
|
341
|
+
* **Defaults to `SAP_RELEASE_DEADLINE_MS`, which is `0` — do not wait.**
|
|
342
|
+
* Waiting is for steps whose successor needs the server to have caught up;
|
|
343
|
+
* a teardown has none. The logoff is still sent at `0`, because saying so
|
|
344
|
+
* is not conditional on caring when it lands; its outcome is logged when it
|
|
345
|
+
* arrives rather than awaited. Pass a positive value to bound a wait you
|
|
346
|
+
* have chosen to take. Anything that is not a finite, non-negative number
|
|
347
|
+
* is reported and the default used instead — this method is called from a
|
|
348
|
+
* `finally`, where throwing would replace the error that sent the caller
|
|
349
|
+
* there. The configured default is checked once, at construction, so a
|
|
350
|
+
* misconfigured `SAP_RELEASE_DEADLINE_MS` fails before a connection exists
|
|
351
|
+
* rather than at every teardown.
|
|
212
352
|
*/
|
|
213
|
-
async disconnect() {
|
|
353
|
+
async disconnect(options) {
|
|
354
|
+
// Nothing here throws, including on a bad argument. This method's place is
|
|
355
|
+
// a `finally` — a connection that was connected must be disconnected — and
|
|
356
|
+
// an exception raised there replaces the error that sent the caller into it.
|
|
357
|
+
// A nonsense deadline is reported and the configured default used instead,
|
|
358
|
+
// because refusing to release the session is a worse answer to a bad number
|
|
359
|
+
// than releasing it on the default schedule.
|
|
360
|
+
const requested = options?.deadlineMs;
|
|
361
|
+
const valid = requested === undefined || (Number.isFinite(requested) && requested >= 0);
|
|
362
|
+
if (!valid) {
|
|
363
|
+
this.logger?.warn(`disconnect(): ignoring deadlineMs=${requested}, which is not a finite, non-negative number; using ${this.releaseDeadlineMs}`);
|
|
364
|
+
}
|
|
365
|
+
const deadlineMs = valid && requested !== undefined ? requested : this.releaseDeadlineMs;
|
|
366
|
+
// Started HERE, because the contract measures the deadline from the call
|
|
367
|
+
// and the transition below may sit in a queue first. A budget that started
|
|
368
|
+
// when the callback ran would give a queued teardown its full allowance
|
|
369
|
+
// again, which is the one thing the caller was bounding.
|
|
370
|
+
const startedAt = Date.now();
|
|
214
371
|
// Synchronous, at the call: admission shuts and the generation moves before
|
|
215
372
|
// anything is queued, so a caller who has asked to disconnect cannot have
|
|
216
373
|
// requests still going through while this waits its turn.
|
|
217
374
|
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
|
|
375
|
+
// Captured before the transition and before anything is cleared: a
|
|
376
|
+
// concurrent disconnect JOINS the transition and its callback is never run
|
|
377
|
+
// for the joiner, so a joiner would otherwise learn nothing about what it
|
|
378
|
+
// asked to release.
|
|
379
|
+
const session = this.getSessionIdentity();
|
|
218
380
|
await this.lifecycle.transition('disconnect', async () => {
|
|
381
|
+
await this.releaseServerSession();
|
|
219
382
|
this.clearSessionState();
|
|
220
383
|
this.lifecycle.markDisconnected();
|
|
221
384
|
});
|
|
385
|
+
// Its own release, and only that. Never another session's: an earlier one
|
|
386
|
+
// may never answer — the logoff carries no request timeout by design — and
|
|
387
|
+
// waiting on it would spend this caller's whole budget on a request nobody
|
|
388
|
+
// can finish.
|
|
389
|
+
const mine = session && this.pendingRelease?.id === session
|
|
390
|
+
? this.pendingRelease.inFlight
|
|
391
|
+
: null;
|
|
392
|
+
await this.awaitReleaseWithin(Math.max(0, deadlineMs - (Date.now() - startedAt)), mine);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Tells the server this session is no longer needed. Best effort, and the
|
|
396
|
+
* answer is not depended on: whether and when the server frees it is its own
|
|
397
|
+
* affair, and this connection does not check afterwards.
|
|
398
|
+
*
|
|
399
|
+
* ICF rather than ADT because ADT publishes no such endpoint: its discovery
|
|
400
|
+
* document lists none on any reachable system — on-prem, cloud, or legacy —
|
|
401
|
+
* and the ADT logon is the discovery call itself. `/sap/public/bc/icf/logoff`
|
|
402
|
+
* is the platform's own, and answers `200` while expiring the session cookie.
|
|
403
|
+
*
|
|
404
|
+
* **One session, this connection's own.** A repeat `connect()` opens a NEW
|
|
405
|
+
* one, with a new `SAP_SESSIONID`, so an earlier session is not this
|
|
406
|
+
* connection's business any more: its logoff is already on the wire, or the
|
|
407
|
+
* system will time it out. Nothing here retries, counts, or keeps a list —
|
|
408
|
+
* how many connections to run and how carefully stays with the caller.
|
|
409
|
+
*
|
|
410
|
+
* **It ends the session, not this object's use of it.** The cookies are the
|
|
411
|
+
* only thing tying anyone to a session, so a second connection given the same
|
|
412
|
+
* cookies works in the same ABAP session and can use the locks taken in it —
|
|
413
|
+
* and this logoff closes that session for all of them at once. Whoever hands
|
|
414
|
+
* the cookies around owns that decision; this method cannot see the copies.
|
|
415
|
+
*
|
|
416
|
+
* Never throws. `disconnect()` must always settle, and a session we could not
|
|
417
|
+
* close is better than a teardown that does not finish; the local state is
|
|
418
|
+
* cleared either way.
|
|
419
|
+
*/
|
|
420
|
+
async releaseServerSession() {
|
|
421
|
+
const id = this.getSessionIdentity();
|
|
422
|
+
const cookies = this.cookies;
|
|
423
|
+
if (!id || !cookies) {
|
|
424
|
+
// No session, or no cookie to prove it is ours. Holding the cookie is the
|
|
425
|
+
// whole permission to close it.
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
// No critical-section guard here, deliberately. `beginTeardown()` above has
|
|
429
|
+
// already shut admission, so the unlock of a chain in flight is refused
|
|
430
|
+
// whether or not the server is told — skipping the goodbye does not rescue
|
|
431
|
+
// the chain, it only leaves the session open. And the depth is decremented
|
|
432
|
+
// by `endCriticalSection()` alone, so one caller forgetting its `finally`
|
|
433
|
+
// would silence every release on this connection for good: a caller's bug
|
|
434
|
+
// turned into the leak this whole change exists to stop. Not disconnecting
|
|
435
|
+
// mid-chain is the caller's to get right.
|
|
436
|
+
// Already on its way for THIS session: a second would tell the server the
|
|
437
|
+
// same thing twice.
|
|
438
|
+
if (this.pendingRelease?.id === id) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// Which mechanism this system publishes was settled at establishment: a
|
|
442
|
+
// session resource to DELETE on ABAP Cloud, the platform's ICF logoff on
|
|
443
|
+
// on-prem. Both say the same thing — we have finished with this session —
|
|
444
|
+
// and neither is asked what the server then did about it.
|
|
445
|
+
// Assembling it can throw before anything is sent — `sessionTransport()`
|
|
446
|
+
// builds the client, and a certificate connection whose material is not
|
|
447
|
+
// loaded throws there. `disconnect()` promises never to throw, and it is
|
|
448
|
+
// called from a `finally`, where an exception would replace the error that
|
|
449
|
+
// sent the caller into it.
|
|
450
|
+
let send;
|
|
451
|
+
try {
|
|
452
|
+
send = this.sessionStrategy.closeSession(this.sessionTransport());
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
this.logger?.debug(`Could not tell the server the session is finished: ${error instanceof Error ? error.message : String(error)}`);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Reported when it lands, waited for or not: "nobody is waiting on it" is
|
|
459
|
+
// not "nobody wants to know". A rejection handler even though `closeSession`
|
|
460
|
+
// is meant never to throw: that is an invariant of another unit, and an
|
|
461
|
+
// unhandled rejection here would crash a process over a teardown the caller
|
|
462
|
+
// declined to wait for.
|
|
463
|
+
const settled = send.then(() => {
|
|
464
|
+
if (this.pendingRelease?.id === id) {
|
|
465
|
+
this.pendingRelease = null;
|
|
466
|
+
}
|
|
467
|
+
}, (error) => {
|
|
468
|
+
if (this.pendingRelease?.id === id) {
|
|
469
|
+
this.pendingRelease = null;
|
|
470
|
+
}
|
|
471
|
+
this.logger?.debug(`Could not tell the server the session is finished: ${error instanceof Error ? error.message : String(error)}`);
|
|
472
|
+
});
|
|
473
|
+
this.pendingRelease = { id, inFlight: settled };
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Waits up to `budgetMs` for a release already on its way, then detaches.
|
|
477
|
+
*
|
|
478
|
+
* Detaching, not cancelling: when the budget runs out this stops waiting and
|
|
479
|
+
* the request carries on to the server. Each caller of `disconnect()` gets
|
|
480
|
+
* its own, so one caller's patience is never charged to another's.
|
|
481
|
+
*/
|
|
482
|
+
async awaitReleaseWithin(budgetMs, release) {
|
|
483
|
+
if (!release || budgetMs === 0) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
// Detaching, not cancelling: when the budget runs out this stops waiting and
|
|
487
|
+
// the request carries on to the server. `unref` so a process that is
|
|
488
|
+
// otherwise done does not stay alive for the timer, and cleared when the
|
|
489
|
+
// release wins the race.
|
|
490
|
+
let expire;
|
|
491
|
+
const deadline = new Promise((resolve) => {
|
|
492
|
+
expire = setTimeout(resolve, budgetMs);
|
|
493
|
+
expire.unref?.();
|
|
494
|
+
});
|
|
495
|
+
// `release` never rejects — its handlers are attached at dispatch — so this
|
|
496
|
+
// needs no catch to keep "never throws" true.
|
|
497
|
+
await Promise.race([release, deadline]);
|
|
498
|
+
if (expire) {
|
|
499
|
+
clearTimeout(expire);
|
|
500
|
+
}
|
|
222
501
|
}
|
|
223
502
|
isConnected() {
|
|
224
503
|
return this.lifecycle.connected;
|
|
@@ -231,22 +510,11 @@ class AbstractAbapConnection {
|
|
|
231
510
|
* session cookie. Use {@link isConnected} for connection state.
|
|
232
511
|
*
|
|
233
512
|
* It follows that null → non-null is not a replacement but an identity being
|
|
234
|
-
* learned; only a CHANGED value means the session
|
|
513
|
+
* learned; only a CHANGED value means the session we had is gone.
|
|
235
514
|
*/
|
|
236
515
|
getSessionIdentity() {
|
|
237
516
|
return this.lifecycle.identity;
|
|
238
517
|
}
|
|
239
|
-
/**
|
|
240
|
-
* Discards the session at a caller's request: cancels queued recoveries and
|
|
241
|
-
* queues the cleanup rather than tearing down under a live request.
|
|
242
|
-
*/
|
|
243
|
-
reset() {
|
|
244
|
-
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: true });
|
|
245
|
-
void this.lifecycle.transition('cleanup', async () => {
|
|
246
|
-
this.clearSessionState();
|
|
247
|
-
this.lifecycle.markDisconnected();
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
518
|
/**
|
|
251
519
|
* Re-establishes the session for a request that is recovering from a
|
|
252
520
|
* credential renewal, then lets that request retry.
|
|
@@ -278,6 +546,105 @@ class AbstractAbapConnection {
|
|
|
278
546
|
* Shared by connect() and recoverSession() rather than written twice —
|
|
279
547
|
* the two drifted apart once already, and a third caller would drift again.
|
|
280
548
|
*/
|
|
549
|
+
/**
|
|
550
|
+
* What a session strategy is given: enough to make one request and to prove
|
|
551
|
+
* the session is ours, and nothing else. It cannot reach session state, so a
|
|
552
|
+
* strategy can neither mark this connection connected nor tear it down.
|
|
553
|
+
*/
|
|
554
|
+
sessionTransport() {
|
|
555
|
+
// A SNAPSHOT, not a view. The close is dispatched without being awaited —
|
|
556
|
+
// a teardown does not wait for it — so `clearSessionState()` runs while the
|
|
557
|
+
// strategy is still suspended on its first `await`. Reading the connection
|
|
558
|
+
// then would find the axios instance already dropped and the cookies gone,
|
|
559
|
+
// and the request would go out through a freshly built client with no
|
|
560
|
+
// session on it, or not at all. Taken here, while they are still true.
|
|
561
|
+
const instance = this.getAxiosInstance();
|
|
562
|
+
const cookies = this.cookies;
|
|
563
|
+
const csrfToken = this.csrfToken;
|
|
564
|
+
return {
|
|
565
|
+
baseUrl: this.baseUrl,
|
|
566
|
+
// The affinity headers ride along with auth: the open must be answered by
|
|
567
|
+
// the server that will hold the session, and the close must reach the one
|
|
568
|
+
// that holds it.
|
|
569
|
+
authHeaders: async () => ({
|
|
570
|
+
...(await this.getAuthHeaders()),
|
|
571
|
+
...this.affinityHeaders(),
|
|
572
|
+
}),
|
|
573
|
+
cookies: () => cookies,
|
|
574
|
+
csrfToken: () => csrfToken,
|
|
575
|
+
send: async (request) => {
|
|
576
|
+
const response = await instance({
|
|
577
|
+
method: request.method,
|
|
578
|
+
url: request.url,
|
|
579
|
+
headers: request.headers,
|
|
580
|
+
...(request.timeoutMs !== undefined
|
|
581
|
+
? { timeout: request.timeoutMs }
|
|
582
|
+
: {}),
|
|
583
|
+
// A 404 is an answer — "this system has no session resource" — and a
|
|
584
|
+
// 403 on a close is the server declining a message. Both belong to
|
|
585
|
+
// the strategy to read, not to axios to throw over.
|
|
586
|
+
validateStatus: () => true,
|
|
587
|
+
});
|
|
588
|
+
// Only the open: its cookies ARE the session — SAP_SESSIONID arrives
|
|
589
|
+
// there — and they go through the same path every other response uses.
|
|
590
|
+
// No generation, because like the establishing CSRF fetch this is what
|
|
591
|
+
// creates the session a generation would be compared against. A close
|
|
592
|
+
// is deliberately not observed: its cookies would read as the session
|
|
593
|
+
// having been replaced under a connection that is being torn down.
|
|
594
|
+
if (request.adoptCookies) {
|
|
595
|
+
// Cookies adopted, verdict NOT asked for. The identity policy answers
|
|
596
|
+
// "did the server move us to a different session while we were
|
|
597
|
+
// working" — and this request is us opening one, with the
|
|
598
|
+
// establishing call still to come. Two requests we make ourselves,
|
|
599
|
+
// back to back, are one establishment; policing between them would
|
|
600
|
+
// read our own second call as somebody replacing our first.
|
|
601
|
+
const headers = response.headers;
|
|
602
|
+
// The open is the first answer that can name the application server,
|
|
603
|
+
// and every request after it should already be pinned there.
|
|
604
|
+
this.rememberAppServer(headers);
|
|
605
|
+
this.updateCookiesFromResponse(headers);
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
status: response.status,
|
|
609
|
+
headers: response.headers,
|
|
610
|
+
data: response.data,
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Ask the server to open a session before the establishing call needs one.
|
|
617
|
+
*
|
|
618
|
+
* The strategy is chosen by what the server publishes, not by which system we
|
|
619
|
+
* believe it to be: ABAP Cloud offers a session resource and issues
|
|
620
|
+
* `SAP_SESSIONID` to whoever asks for one — with `x-sap-security-session:
|
|
621
|
+
* create` — while on-prem has no such resource and its session arrives with
|
|
622
|
+
* the establishing request. Believing cloud simply "issues no SAP_SESSIONID"
|
|
623
|
+
* is what happens when nobody asks.
|
|
624
|
+
*/
|
|
625
|
+
/**
|
|
626
|
+
* Which session management this system uses — decided by the connection, not
|
|
627
|
+
* discovered by asking.
|
|
628
|
+
*
|
|
629
|
+
* On-prem and cloud do not manage sessions the same way, and the two
|
|
630
|
+
* implementations exist for that reason. On-prem the session arrives with the
|
|
631
|
+
* establishing request and the platform's ICF logoff gives it back, exactly as
|
|
632
|
+
* it always has. Cloud opens a session resource and takes it back by DELETE on
|
|
633
|
+
* the address the server published.
|
|
634
|
+
*
|
|
635
|
+
* Probing was tried and is wrong: `/sap/bc/adt/core/http/sessions` answers on
|
|
636
|
+
* on-prem too — measured on S/4HANA, which publishes both the session resource
|
|
637
|
+
* and the ICF logoff in the same document — so a probe does not tell the two
|
|
638
|
+
* systems apart. It only tells whether an endpoint exists, and both have it.
|
|
639
|
+
*/
|
|
640
|
+
createSessionStrategy() {
|
|
641
|
+
return new IcfSessionStrategy_js_1.IcfSessionStrategy(this.logger);
|
|
642
|
+
}
|
|
643
|
+
async openServerSession() {
|
|
644
|
+
this.sessionStrategy = this.createSessionStrategy();
|
|
645
|
+
this.preflightOpenedSession = await this.sessionStrategy.openSession(this.sessionTransport());
|
|
646
|
+
this.logger?.debug(`Session strategy: ${this.sessionStrategy.kind}`);
|
|
647
|
+
}
|
|
281
648
|
async establishAndCommit(baselineEpoch) {
|
|
282
649
|
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
283
650
|
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
|
|
@@ -289,6 +656,21 @@ class AbstractAbapConnection {
|
|
|
289
656
|
// makes the new fingerprint `established`, which is what it is.
|
|
290
657
|
this.lifecycle.forgetIdentity();
|
|
291
658
|
try {
|
|
659
|
+
// First, because the preflight below has to be able to authenticate: the
|
|
660
|
+
// credential of a certificate or Kerberos connection is not in hand until
|
|
661
|
+
// it is loaded or minted, and assembling a request without it throws.
|
|
662
|
+
await this.prepareCredential();
|
|
663
|
+
// Before the establishing call, because on a system that has one this is
|
|
664
|
+
// what creates the session the rest of the connection runs in — and the
|
|
665
|
+
// cookies it sets are the ones the establishing call must carry.
|
|
666
|
+
await this.openServerSession();
|
|
667
|
+
// Forgotten again, because the open was OURS. Establishment is now two
|
|
668
|
+
// requests where it used to be one, and the identity policy answers "did
|
|
669
|
+
// the server move us to a different session while we were working" — a
|
|
670
|
+
// question that has no meaning between two calls we make ourselves to set
|
|
671
|
+
// this session up. The identity that counts is taken at the end, from the
|
|
672
|
+
// cookies we finish with.
|
|
673
|
+
this.lifecycle.forgetIdentity();
|
|
292
674
|
await this.establishSession();
|
|
293
675
|
}
|
|
294
676
|
catch (error) {
|
|
@@ -302,6 +684,24 @@ class AbstractAbapConnection {
|
|
|
302
684
|
// Safe to clear here, unlike the abandonment path below: establishSession()
|
|
303
685
|
// threw, so no session was published, and admission requires a connected
|
|
304
686
|
// lifecycle — nothing can be in flight over what this drops.
|
|
687
|
+
//
|
|
688
|
+
// But say goodbye FIRST. The preflight may already have opened a session
|
|
689
|
+
// — on cloud it does, and the SAP_SESSIONID is in the cookies about to be
|
|
690
|
+
// dropped — and establishing can still fail after it, on a credential the
|
|
691
|
+
// preflight never used. Clearing without telling the server would leak
|
|
692
|
+
// exactly the session this release exists to stop leaking, and leave it
|
|
693
|
+
// unreachable: the connection is not connected, so disconnect() sends
|
|
694
|
+
// nothing, and the cookie that was the only permission to close it is
|
|
695
|
+
// gone. The transport is a snapshot, so this survives the clearing that
|
|
696
|
+
// follows it.
|
|
697
|
+
// Only when the preflight opened one. A cookie left by the 401 that
|
|
698
|
+
// rejected us is debris, not a session, and telling the server we are
|
|
699
|
+
// finished with something we never had sends a request nobody asked for
|
|
700
|
+
// — into the middle of an authentication exchange, in the case that found
|
|
701
|
+
// this.
|
|
702
|
+
const goodbye = this.preflightOpenedSession
|
|
703
|
+
? this.sessionStrategy.closeSession(this.sessionTransport())
|
|
704
|
+
: Promise.resolve();
|
|
305
705
|
this.invalidateSession();
|
|
306
706
|
// And the identity with it. The rejecting response was still observed, so
|
|
307
707
|
// its cookie was recorded as a session that had just been established —
|
|
@@ -309,6 +709,10 @@ class AbstractAbapConnection {
|
|
|
309
709
|
// isConnected() says false. Two answers to one question is worse than
|
|
310
710
|
// either.
|
|
311
711
|
this.lifecycle.markDisconnected();
|
|
712
|
+
// Not awaited, for the same reason a teardown does not wait: the caller
|
|
713
|
+
// is owed the establishment error now, not after a round trip nobody is
|
|
714
|
+
// waiting on. closeSession never throws, so nothing here can go unhandled.
|
|
715
|
+
void goodbye;
|
|
312
716
|
throw error;
|
|
313
717
|
}
|
|
314
718
|
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
@@ -319,15 +723,79 @@ class AbstractAbapConnection {
|
|
|
319
723
|
// from inside the guard meant to protect it.
|
|
320
724
|
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested while it was in flight');
|
|
321
725
|
}
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
726
|
+
// The session IS the SAP_SESSIONID the server issued; our own session id is
|
|
727
|
+
// a conversation label we generate and says nothing about what exists on
|
|
728
|
+
// the other side. An empty fingerprint therefore means the server opened no
|
|
729
|
+
// session — on-prem it answers with `sap-XSRF_*` instead once enough
|
|
730
|
+
// sessions are already open for the user — and such a connection still gets
|
|
731
|
+
// `200` for a LOCK and hands back a handle the next request cannot use.
|
|
732
|
+
//
|
|
733
|
+
// SAP_SESSIONID names the ABAP session — the one locks are bound to. Its
|
|
734
|
+
// absence is therefore not a transport problem: the HTTP side is fine, the
|
|
735
|
+
// cookies are here, and stateless requests will work. What is missing is any
|
|
736
|
+
// ABAP session known to this connection, so there is nothing a lock could be
|
|
737
|
+
// bound to and every lock taken over it is dead the moment it is issued.
|
|
738
|
+
//
|
|
739
|
+
// Checked rather than assumed: a connection that got no cookie was held open
|
|
740
|
+
// against an on-prem system and the session list showed nothing for it,
|
|
741
|
+
// while one that got a cookie appeared there.
|
|
742
|
+
//
|
|
743
|
+
// Which is why this refuses to connect rather than warning. There is no
|
|
744
|
+
// count to plan around — the same system allowed 21 sessions one day and
|
|
745
|
+
// refused an eleventh the next — so a caller cannot avoid the condition by
|
|
746
|
+
// being frugal, and the only reliable signal is whether THIS connect got a
|
|
747
|
+
// session. Reported with its cause; recovering is the caller's call, and
|
|
748
|
+
// nothing here retries on anyone's behalf.
|
|
749
|
+
//
|
|
750
|
+
// Reported, not decided on. A session that was not opened is a condition on
|
|
751
|
+
// the server, and what to do about it — wait, retry, release sessions this
|
|
752
|
+
// user still holds, carry on read-only over a fresh connection — depends on
|
|
753
|
+
// things only the caller knows. So it is raised where the caller can catch
|
|
754
|
+
// it, with enough in the message to act on, and nothing is retried here.
|
|
755
|
+
//
|
|
756
|
+
// Every transport, not only basic: splitting by authentication type would
|
|
757
|
+
// encode a guess about cloud ABAP, whose ADT endpoint would not answer the
|
|
758
|
+
// bearer obtainable here, so the question stayed open. If a cloud system
|
|
759
|
+
// turns out to hold sessions without issuing this cookie, this is the rule
|
|
760
|
+
// to revisit — and it will say so loudly rather than fail quietly.
|
|
761
|
+
const fingerprint = this.sessionFingerprint();
|
|
762
|
+
if (fingerprint.size === 0 && !this.skipSessionType) {
|
|
763
|
+
// Goodbye first, for the same reason the catch above does it: the
|
|
764
|
+
// preflight may have opened a session — on cloud it does — and this path
|
|
765
|
+
// is about to drop the cookies that are the only permission to close it.
|
|
766
|
+
// Refusing to connect must not leak the session the refusal is about.
|
|
767
|
+
if (this.preflightOpenedSession) {
|
|
768
|
+
try {
|
|
769
|
+
void this.sessionStrategy.closeSession(this.sessionTransport());
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
this.logger?.debug(`Could not tell the server the session is finished: ${error instanceof Error ? error.message : String(error)}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
this.invalidateSession();
|
|
776
|
+
this.lifecycle.forgetIdentity();
|
|
777
|
+
this.lifecycle.markDisconnected();
|
|
778
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'The server authenticated the request but opened no ABAP session: no SAP_SESSIONID cookie came back, so there is no session for a lock to be bound to. The HTTP side is fine — the cookies are here — which is why this is not a transport failure and does not look like one. ' +
|
|
779
|
+
'Stateless reads would still work over it, but a lock, and any write under that lock, is dead the moment it is issued. ' +
|
|
780
|
+
'The usual cause is the system declining to open another session for this user: they are limited per user, shared with every other tool logged on as them, and released either by disconnecting or by their own idle timeout. ' +
|
|
781
|
+
'Whether to wait, retry, or release sessions this user still holds is yours to decide — this library does not retry on your behalf.');
|
|
782
|
+
}
|
|
783
|
+
this.lifecycle.markConnected(fingerprint);
|
|
326
784
|
}
|
|
327
785
|
/** The teardown epoch, for a recovery to capture before it starts. */
|
|
328
786
|
get teardownEpoch() {
|
|
329
787
|
return this.lifecycle.teardownEpoch;
|
|
330
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* Which session the connection is on now.
|
|
791
|
+
*
|
|
792
|
+
* Moves whenever the session does. A response that comes back carrying an
|
|
793
|
+
* older one belongs to a session that has already been replaced, and must not
|
|
794
|
+
* be acted on as if it said something about the current one.
|
|
795
|
+
*/
|
|
796
|
+
get sessionGeneration() {
|
|
797
|
+
return this.lifecycle.sessionGeneration;
|
|
798
|
+
}
|
|
331
799
|
/**
|
|
332
800
|
* Raises a session-lost teardown from inside request handling.
|
|
333
801
|
*
|
|
@@ -398,8 +866,41 @@ class AbstractAbapConnection {
|
|
|
398
866
|
this.logger?.debug('Ignoring a response from a previous session: its effects are fenced');
|
|
399
867
|
return;
|
|
400
868
|
}
|
|
869
|
+
this.rememberAppServer(headers);
|
|
401
870
|
this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
|
|
402
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* Take the application server's name from a response, if it named one.
|
|
874
|
+
*
|
|
875
|
+
* Only ever set from the server's own answer — never guessed, and never kept
|
|
876
|
+
* across a teardown.
|
|
877
|
+
*/
|
|
878
|
+
rememberAppServer(headers) {
|
|
879
|
+
if (!headers)
|
|
880
|
+
return;
|
|
881
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === 'sap-adt-saplb');
|
|
882
|
+
const value = key ? headers[key] : undefined;
|
|
883
|
+
if (typeof value === 'string' && value && value !== this.appServer) {
|
|
884
|
+
this.appServer = value;
|
|
885
|
+
this.logger?.debug(`Session is on application server ${value}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Headers that keep this connection on the server its session lives on.
|
|
890
|
+
*
|
|
891
|
+
* `sap-adt-saplb: fetch` asks the server to name itself — it answers on every
|
|
892
|
+
* request, so the binding survives a restart that moves us. `saplb` is that
|
|
893
|
+
* name sent back. `REDISPATCH_ON_SHUTDOWN` is what Eclipse asks for: if the
|
|
894
|
+
* server is going down, send us elsewhere rather than fail.
|
|
895
|
+
*/
|
|
896
|
+
affinityHeaders() {
|
|
897
|
+
return {
|
|
898
|
+
'sap-adt-saplb': 'fetch',
|
|
899
|
+
...(this.appServer
|
|
900
|
+
? { saplb: this.appServer, 'saplb-options': 'REDISPATCH_ON_SHUTDOWN' }
|
|
901
|
+
: {}),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
403
904
|
/**
|
|
404
905
|
* Acts on what a response said about the session identity.
|
|
405
906
|
*
|
|
@@ -420,12 +921,14 @@ class AbstractAbapConnection {
|
|
|
420
921
|
if (classification !== 'replaced')
|
|
421
922
|
return;
|
|
422
923
|
this.raiseSessionLost('the session cookie changed under us');
|
|
423
|
-
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session was
|
|
924
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session this connection was using is gone and the requests are now on a different one; anything held against the old session — a lock and any write under it — is dead. ' +
|
|
925
|
+
'The server does not swap sessions on a whim: the usual causes are the session idling out (the timeout is idle-based, so a quiet connection loses it while a busy one does not) or a request landing on a different application server. ' +
|
|
926
|
+
'What to do about it is yours: re-establish and redo the work, or fail the operation. Nothing is retried here.');
|
|
424
927
|
}
|
|
425
928
|
/**
|
|
426
929
|
* Whether the server is telling us the session it was given no longer exists.
|
|
427
930
|
*
|
|
428
|
-
*
|
|
931
|
+
* One on-prem system answered HTTP 400 with "Session not found", answered in ~60 ms
|
|
429
932
|
* with the cookie present — which is why identity comparison cannot see this:
|
|
430
933
|
* the cookie, and therefore the fingerprint, is completely unchanged. The
|
|
431
934
|
* exact match is landscape-specific and is one of the live probes this design
|
|
@@ -453,6 +956,9 @@ class AbstractAbapConnection {
|
|
|
453
956
|
}
|
|
454
957
|
this.csrfToken = null;
|
|
455
958
|
this.cookies = null;
|
|
959
|
+
// Names a server for a session that no longer exists.
|
|
960
|
+
this.appServer = null;
|
|
961
|
+
this.preflightOpenedSession = false;
|
|
456
962
|
this.cookieStore.clear();
|
|
457
963
|
// Note: baseUrl is not reset as it's derived from immutable config
|
|
458
964
|
}
|
|
@@ -536,6 +1042,11 @@ class AbstractAbapConnection {
|
|
|
536
1042
|
if (this.sessionId) {
|
|
537
1043
|
requestHeaders['sap-adt-connection-id'] = this.sessionId;
|
|
538
1044
|
}
|
|
1045
|
+
// Keep this request on the server the session lives on. A session belongs
|
|
1046
|
+
// to one application server, so a request that lands elsewhere gets a
|
|
1047
|
+
// different session — and any lock held on the first one dies, with no
|
|
1048
|
+
// inactivity and nobody at fault.
|
|
1049
|
+
Object.assign(requestHeaders, this.affinityHeaders());
|
|
539
1050
|
// Add stateful session headers if stateful mode is enabled
|
|
540
1051
|
if (this.sessionMode === 'stateful') {
|
|
541
1052
|
requestHeaders['x-sap-adt-sessiontype'] = 'stateful';
|
|
@@ -550,9 +1061,10 @@ class AbstractAbapConnection {
|
|
|
550
1061
|
this.csrfToken) {
|
|
551
1062
|
requestHeaders['x-csrf-token'] = this.csrfToken;
|
|
552
1063
|
}
|
|
553
|
-
// Add cookies LAST (MUST NOT be overridden by custom headers)
|
|
1064
|
+
// Add cookies LAST (MUST NOT be overridden by custom headers), MERGED with
|
|
1065
|
+
// whatever the auth headers already put there — see mergeCookieHeaders.
|
|
554
1066
|
if (this.cookies) {
|
|
555
|
-
requestHeaders.Cookie = this.cookies;
|
|
1067
|
+
requestHeaders.Cookie = (0, cookies_js_1.mergeCookieHeaders)(requestHeaders.Cookie, this.cookies);
|
|
556
1068
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to request (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
557
1069
|
}
|
|
558
1070
|
else {
|
|
@@ -822,10 +1334,13 @@ class AbstractAbapConnection {
|
|
|
822
1334
|
if (this.sessionId) {
|
|
823
1335
|
headers['sap-adt-connection-id'] = this.sessionId;
|
|
824
1336
|
}
|
|
1337
|
+
// Same reason as every other request: a token fetched from another
|
|
1338
|
+
// application server belongs to another session.
|
|
1339
|
+
Object.assign(headers, this.affinityHeaders());
|
|
825
1340
|
// Always add cookies if available - they are needed for session continuity
|
|
826
1341
|
// Even on first attempt, if we have cookies from previous session or error response, use them
|
|
827
1342
|
if (this.cookies) {
|
|
828
|
-
headers.Cookie = this.cookies;
|
|
1343
|
+
headers.Cookie = (0, cookies_js_1.mergeCookieHeaders)(headers.Cookie, this.cookies);
|
|
829
1344
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to CSRF token request (attempt ${attempt + 1}, first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
830
1345
|
}
|
|
831
1346
|
else {
|
|
@@ -1066,12 +1581,21 @@ class AbstractAbapConnection {
|
|
|
1066
1581
|
* cookies (HTTP 401 on a mutation while a cached token exists). This forces the
|
|
1067
1582
|
* next request path to fetch a fresh token and a fresh SAP_SESSIONID cookie.
|
|
1068
1583
|
*
|
|
1069
|
-
* Distinct from
|
|
1584
|
+
* Distinct from disconnect(): this leaves the axios instance and interceptors
|
|
1585
|
+
* in place, and tells the server nothing — it is a request-level repair, not a
|
|
1586
|
+
* teardown.
|
|
1070
1587
|
*/
|
|
1071
1588
|
invalidateSession() {
|
|
1072
1589
|
this.setCsrfToken(null);
|
|
1073
1590
|
this.cookies = null;
|
|
1074
1591
|
this.cookieStore.clear();
|
|
1592
|
+
// Everything else that described THAT session. The application server named
|
|
1593
|
+
// a server for a session that is gone — sending it again would pin the next
|
|
1594
|
+
// connect, preflight included, to a dead one — and the preflight flag would
|
|
1595
|
+
// otherwise let a later failure send a goodbye to the previous session's
|
|
1596
|
+
// address.
|
|
1597
|
+
this.appServer = null;
|
|
1598
|
+
this.preflightOpenedSession = false;
|
|
1075
1599
|
// And the tracked identity, because WE discarded the session. Without this
|
|
1076
1600
|
// the cookie that arrives next reads as a foreign replacement — and since a
|
|
1077
1601
|
// replacement is now always fatal, our own deliberate re-authentication
|