@mcp-abap-adt/connection 9.3.1 → 9.3.4
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 +56 -1
- package/README.md +8 -0
- package/dist/connection/HttpTransport.d.ts +1 -9
- package/dist/connection/HttpTransport.d.ts.map +1 -1
- package/dist/connection/HttpTransport.js +7 -28
- package/dist/connection/LegacyOnPremHttpTransport.d.ts.map +1 -1
- package/dist/connection/LegacyOnPremHttpTransport.js +2 -2
- package/dist/connection/RfcTransport.d.ts +73 -3
- package/dist/connection/RfcTransport.d.ts.map +1 -1
- package/dist/connection/RfcTransport.js +186 -7
- package/dist/connection/statefulRequest.d.ts +19 -0
- package/dist/connection/statefulRequest.d.ts.map +1 -0
- package/dist/connection/statefulRequest.js +25 -0
- package/docs/STATEFUL_SESSION_GUIDE.md +24 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [9.3.4] - 2026-09-27
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Release the RFC context reset under an available package version.** npm
|
|
15
|
+
already contained version `9.3.3`, so this release carries the RFC stateless
|
|
16
|
+
context reset as `9.3.4`. With `sap-rfc-lite` 0.2.0, stateless calls reuse a
|
|
17
|
+
conversation and reset its server context after each call; older clients
|
|
18
|
+
continue to open a conversation per call. See the `9.3.3` entry below for
|
|
19
|
+
measured behavior and performance.
|
|
20
|
+
|
|
21
|
+
## [9.3.3] - 2026-09-27
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Over RFC, stateless calls reuse a conversation and reset its server
|
|
26
|
+
context when supported.** `@mcp-abap-adt/sap-rfc-lite` 0.2.0 adds
|
|
27
|
+
`resetServerContext()` (`RfcResetServerContext`), so each call gets a clean
|
|
28
|
+
ABAP context without logging in again. Measured on E19, this took about
|
|
29
|
+
0.24 s per call, compared with 0.67 s when opening a conversation each time.
|
|
30
|
+
Concurrent calls use a separate conversation so they cannot run between a
|
|
31
|
+
previous call and its reset. If reset fails, that conversation is closed.
|
|
32
|
+
With `sap-rfc-lite` 0.1.x, calls continue to use a new conversation each.
|
|
33
|
+
Create, read after create, two lock/write/unlock rounds and delete passed on
|
|
34
|
+
one E19 connection.
|
|
35
|
+
|
|
36
|
+
## [9.3.2] - 2026-09-27
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **Over RFC, a request that is not stateful runs on a conversation of its
|
|
41
|
+
own.** `RfcTransport` carried everything on one conversation, one ABAP
|
|
42
|
+
session, so whatever a program left in it stayed:
|
|
43
|
+
- after a package was created or written, every further write or delete of
|
|
44
|
+
it answered 400 PAK/058 "Package … is already locked" (`CL_PACKAGE`'s
|
|
45
|
+
instance buffer);
|
|
46
|
+
- a read straight after a create answered 400 SADT_RESOURCE/007.
|
|
47
|
+
|
|
48
|
+
Now the conversation `open()` makes carries the requests marked `stateful`
|
|
49
|
+
(the lock and its release), and every other request opens one of its own
|
|
50
|
+
and closes it when it answers. That is Eclipse ADT's split over JCo: one
|
|
51
|
+
stateful enqueue session, every other call in a session of its own.
|
|
52
|
+
Measured on E19, one connection: create, read, two lock/PUT/unlock rounds
|
|
53
|
+
and a delete all pass. The price is a logon per non-stateful call — about
|
|
54
|
+
0.5 s on E19, against about 0.1 s for the call itself.
|
|
55
|
+
|
|
56
|
+
Stateful is decided the same way on both wires (`isStatefulRequest`): the
|
|
57
|
+
connection's flag, or a session header the caller wrote itself. And a call
|
|
58
|
+
whose own conversation is still logging on when the wire is closed sends
|
|
59
|
+
nothing: it closes that conversation and refuses, rather than delivering a
|
|
60
|
+
`PUT` or `DELETE` on a wire already given back.
|
|
61
|
+
|
|
10
62
|
## [9.3.1] - 2026-09-27
|
|
11
63
|
|
|
12
64
|
### Fixed
|
|
@@ -1831,7 +1883,10 @@ const connection = createAbapConnection(config, logger);
|
|
|
1831
1883
|
- JWT token refresh now properly handles connection errors (401/403 during initial connect)
|
|
1832
1884
|
- Permission errors (403 with "ExceptionResourceNoAccess") no longer trigger JWT refresh loops
|
|
1833
1885
|
- Proper separation: base class handles HTTP/session, concrete classes handle auth-specific errors
|
|
1834
|
-
[Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/v9.3.
|
|
1886
|
+
[Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/v9.3.4...HEAD
|
|
1887
|
+
[9.3.4]: https://github.com/fr0ster/mcp-abap-connection/compare/v9.3.3...v9.3.4
|
|
1888
|
+
[9.3.3]: https://github.com/fr0ster/mcp-abap-connection/compare/v9.3.2...v9.3.3
|
|
1889
|
+
[9.3.2]: https://github.com/fr0ster/mcp-abap-connection/compare/v9.3.1...v9.3.2
|
|
1835
1890
|
[9.3.1]: https://github.com/fr0ster/mcp-abap-connection/compare/v9.3.0...v9.3.1
|
|
1836
1891
|
[9.3.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v9.2.2...v9.3.0
|
|
1837
1892
|
[9.2.2]: https://github.com/fr0ster/mcp-abap-connection/compare/v9.2.1...v9.2.2
|
package/README.md
CHANGED
|
@@ -292,6 +292,14 @@ where stateful HTTP sessions are not usable: an RFC conversation is one ABAP
|
|
|
292
292
|
session for its whole lifetime, which is the way past `423 invalid lock handle`
|
|
293
293
|
on BASIS < 7.50.
|
|
294
294
|
|
|
295
|
+
Since 9.3.2, LOCK and UNLOCK in stateful mode share the persistent RFC
|
|
296
|
+
conversation; stateless requests use a separate ABAP context. With
|
|
297
|
+
`sap-rfc-lite` 0.2.0 or newer, that context is reset between calls on a kept
|
|
298
|
+
conversation; older clients open one conversation per call. This prevents stale
|
|
299
|
+
package state from causing PAK/058 on later writes or SADT_RESOURCE/007 on a
|
|
300
|
+
read after create. Concurrent calls use a separate conversation. See
|
|
301
|
+
[RFC conversation isolation](./docs/STATEFUL_SESSION_GUIDE.md#rfc-conversation-isolation-932-and-933).
|
|
302
|
+
|
|
295
303
|
Needs the SAP NW RFC SDK on the machine and `npm install @mcp-abap-adt/sap-rfc-lite`.
|
|
296
304
|
|
|
297
305
|
```typescript
|
|
@@ -21,15 +21,7 @@
|
|
|
21
21
|
import { type AgentOptions } from 'node:https';
|
|
22
22
|
import type { ILogger } from '../logger.js';
|
|
23
23
|
import type { IAdtEstablishContext, IAdtSessionContext, IAdtTransport, IAdtTransportRequest, IAdtTransportResponse } from './IAdtTransport.js';
|
|
24
|
-
|
|
25
|
-
* Whether the request runs in the stateful session: marked so by the
|
|
26
|
-
* connection, or — kept for callers that still write it themselves — carrying
|
|
27
|
-
* the session header already.
|
|
28
|
-
*
|
|
29
|
-
* Exported for the wires that rewrite a request before sending it: the
|
|
30
|
-
* verdict has to be taken on the request as the caller made it.
|
|
31
|
-
*/
|
|
32
|
-
export declare function isStatefulRequest(request: IAdtTransportRequest): boolean;
|
|
24
|
+
export { isStatefulRequest } from './statefulRequest.js';
|
|
33
25
|
export declare class HttpTransport implements IAdtTransport {
|
|
34
26
|
private readonly agentOptions;
|
|
35
27
|
protected readonly logger: ILogger | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HttpTransport.d.ts","sourceRoot":"","sources":["../../src/connection/HttpTransport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAG5C,OAAO,KAAK,EACV,oBAAoB,EACpB,kBAAkB,EAClB,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACtB,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"HttpTransport.d.ts","sourceRoot":"","sources":["../../src/connection/HttpTransport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAG5C,OAAO,KAAK,EACV,oBAAoB,EACpB,kBAAkB,EAClB,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACtB,MAAM,oBAAoB,CAAC;AAuC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AASzD,qBAAa,aAAc,YAAW,aAAa;IA6B/C,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI;IACzC;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO;IApC1B;;;;;;;;;OASG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAU;IAE/B,OAAO,CAAC,QAAQ,CAA8B;IAE9C;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,GAAG,CAA6B;IACjD,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,KAAK,CAAuB;gBAGjB,YAAY,GAAE,MAAM,YAAyB,EAC3C,MAAM,GAAE,OAAO,GAAG,IAAW;IAChD;;;;;OAKG;IACc,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO;IAGtE;;;;;;OAMG;IACH,MAAM,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgC/C,0EAA0E;IAC1E,OAAO,IAAI,MAAM,GAAG,IAAI;IAIxB;;;;;;OAMG;IACH,kBAAkB,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;IAQzC;;;;;OAKG;IACH,kBAAkB,IAAI,OAAO;IAI7B;;;;;;;OAOG;IACH,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IASzC;;;;;;;OAOG;IACH,SAAS,CAAC,cAAc,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAIlD;;;;;;;OAOG;IACG,SAAS,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmG7D,uEAAuE;IACvE,SAAS,IAAI,MAAM,GAAG,IAAI;IAI1B,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAI1C,4EAA4E;IAC5E,aAAa,IAAI,IAAI;IASrB;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAWzB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,KAAK;IA6Bb;;;;;OAKG;IACH,SAAS,CAAC,kBAAkB,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAItD,0EAA0E;IAC1E,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,MAAM;IAuBd;;;;;;OAMG;IACG,IAAI,CAAC,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAEvD;;;;;;OAMG;IACG,KAAK,CAAC,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAExD;;;;;OAKG;IACG,IAAI,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAIzE;;;;;;;;;;;;;OAaG;cACa,YAAY,CAC1B,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;YAInB,QAAQ;CA4BvB"}
|
|
@@ -23,12 +23,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
23
23
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
24
24
|
};
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.HttpTransport = void 0;
|
|
27
|
-
exports.isStatefulRequest = isStatefulRequest;
|
|
26
|
+
exports.HttpTransport = exports.isStatefulRequest = void 0;
|
|
28
27
|
const node_https_1 = require("node:https");
|
|
29
28
|
const axios_1 = __importDefault(require("axios"));
|
|
30
29
|
const cookies_js_1 = require("../utils/cookies.js");
|
|
31
30
|
const csrfConfig_js_1 = require("./csrfConfig.js");
|
|
31
|
+
const statefulRequest_js_1 = require("./statefulRequest.js");
|
|
32
32
|
/** The cookie that names a stateful ABAP context. */
|
|
33
33
|
const CONTEXT_COOKIE = 'sap-contextid';
|
|
34
34
|
/**
|
|
@@ -63,30 +63,9 @@ function withoutContextCookie(cookie) {
|
|
|
63
63
|
.join('; ');
|
|
64
64
|
return kept || undefined;
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
* Whether the request runs in the stateful session: marked so by the
|
|
70
|
-
* connection, or — kept for callers that still write it themselves — carrying
|
|
71
|
-
* the session header already.
|
|
72
|
-
*
|
|
73
|
-
* Exported for the wires that rewrite a request before sending it: the
|
|
74
|
-
* verdict has to be taken on the request as the caller made it.
|
|
75
|
-
*/
|
|
76
|
-
function isStatefulRequest(request) {
|
|
77
|
-
if (request.stateful)
|
|
78
|
-
return true;
|
|
79
|
-
const headers = request.headers;
|
|
80
|
-
if (!headers)
|
|
81
|
-
return false;
|
|
82
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
83
|
-
if (name.toLowerCase() === SESSION_TYPE_HEADER &&
|
|
84
|
-
String(value).toLowerCase() === 'stateful') {
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
66
|
+
// The one verdict every wire takes; re-exported for the wires built on this one.
|
|
67
|
+
var statefulRequest_js_2 = require("./statefulRequest.js");
|
|
68
|
+
Object.defineProperty(exports, "isStatefulRequest", { enumerable: true, get: function () { return statefulRequest_js_2.isStatefulRequest; } });
|
|
90
69
|
/** A 404 there means the system has no such endpoint, not that it is unwell. */
|
|
91
70
|
function absentEndpoint(error) {
|
|
92
71
|
const status = error
|
|
@@ -368,7 +347,7 @@ class HttpTransport {
|
|
|
368
347
|
*/
|
|
369
348
|
dress(request) {
|
|
370
349
|
const headers = request.headers;
|
|
371
|
-
const stateful = isStatefulRequest(request);
|
|
350
|
+
const stateful = (0, statefulRequest_js_1.isStatefulRequest)(request);
|
|
372
351
|
const dressed = {
|
|
373
352
|
...this.affinityHeaders(),
|
|
374
353
|
...this.sessionHeaders(),
|
|
@@ -404,7 +383,7 @@ class HttpTransport {
|
|
|
404
383
|
* `LegacyOnPremHttpTransport`).
|
|
405
384
|
*/
|
|
406
385
|
sessionTypeHeaders() {
|
|
407
|
-
return { [SESSION_TYPE_HEADER]: 'stateful' };
|
|
386
|
+
return { [statefulRequest_js_1.SESSION_TYPE_HEADER]: 'stateful' };
|
|
408
387
|
}
|
|
409
388
|
/** A path becomes an address; anything already absolute is left alone. */
|
|
410
389
|
address(url) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LegacyOnPremHttpTransport.d.ts","sourceRoot":"","sources":["../../src/connection/LegacyOnPremHttpTransport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;
|
|
1
|
+
{"version":3,"file":"LegacyOnPremHttpTransport.d.ts","sourceRoot":"","sources":["../../src/connection/LegacyOnPremHttpTransport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EACV,oBAAoB,EACpB,qBAAqB,EACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAK/D,qBAAa,yBAA0B,SAAQ,mBAAmB;IAChE,SAAkB,IAAI,wBAAwB;IAE9C,mFAAmF;cAChE,kBAAkB,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAI/D;;;;;;;;;OASG;IACY,IAAI,CACjB,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IAUjC,OAAO,CAAC,kBAAkB;IAY1B;;;;;;;OAOG;IACM,kBAAkB,IAAI,OAAO;CAGvC"}
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
exports.LegacyOnPremHttpTransport = void 0;
|
|
18
|
-
const HttpTransport_js_1 = require("./HttpTransport.js");
|
|
19
18
|
const OnPremHttpTransport_js_1 = require("./OnPremHttpTransport.js");
|
|
19
|
+
const statefulRequest_js_1 = require("./statefulRequest.js");
|
|
20
20
|
const SESSION_TYPE_HEADER = 'x-sap-adt-sessiontype';
|
|
21
21
|
class LegacyOnPremHttpTransport extends OnPremHttpTransport_js_1.OnPremHttpTransport {
|
|
22
22
|
kind = 'onprem-http-legacy';
|
|
@@ -40,7 +40,7 @@ class LegacyOnPremHttpTransport extends OnPremHttpTransport_js_1.OnPremHttpTrans
|
|
|
40
40
|
// cookie, only without the header this system is hurt by.
|
|
41
41
|
return super.send({
|
|
42
42
|
...this.withoutSessionType(request),
|
|
43
|
-
stateful: (0,
|
|
43
|
+
stateful: (0, statefulRequest_js_1.isStatefulRequest)(request),
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
withoutSessionType(request) {
|
|
@@ -11,12 +11,32 @@
|
|
|
11
11
|
* "invalid lock handle" on legacy systems (BASIS < 7.50) where stateful HTTP
|
|
12
12
|
* sessions are not usable.
|
|
13
13
|
*
|
|
14
|
+
* **Two conversations, the way Eclipse ADT holds them over JCo.** Its trace
|
|
15
|
+
* shows one long-lived stateful session (`JCoEnqueueSystemSession`) for `LOCK`
|
|
16
|
+
* and `UNLOCK`, and every other call — the `PUT` included — in a session of
|
|
17
|
+
* its own. Here:
|
|
18
|
+
*
|
|
19
|
+
* - the persistent conversation, opened by `open()` and kept while this wire
|
|
20
|
+
* lives, carries the requests marked `stateful` — the lock and what must
|
|
21
|
+
* share its session;
|
|
22
|
+
* - every other request opens a conversation of its own, and closes it when
|
|
23
|
+
* it answers.
|
|
24
|
+
*
|
|
25
|
+
* One conversation for everything kept whatever an ABAP program left in the
|
|
26
|
+
* session. `CL_PACKAGE` buffers its instances for the life of the session and
|
|
27
|
+
* a save leaves them changeable, so after a create or a write every further
|
|
28
|
+
* write or delete of that package answered 400 PAK/058 "Package … is already
|
|
29
|
+
* locked"; a read straight after a create answered 400 SADT_RESOURCE/007.
|
|
30
|
+
* Measured on E19 (2026-09-27): LOCK and UNLOCK on one conversation, the PUT
|
|
31
|
+
* on another — two writes in a row fail on a shared second conversation and
|
|
32
|
+
* pass on a fresh one per call. The price is a logon per non-stateful call
|
|
33
|
+
* (~0.5 s on E19, against ~0.1 s for the call itself).
|
|
34
|
+
*
|
|
14
35
|
* **What is NOT here.** Cookies, the CSRF token and the session lifecycle
|
|
15
36
|
* belong to the connection above this seam and are already in
|
|
16
37
|
* `request.headers` by the time `send()` is called. `x-sap-adt-sessiontype` is
|
|
17
|
-
* not sent at all: `request.stateful`
|
|
18
|
-
* Eclipse's JCo trace carries no session header
|
|
19
|
-
* connection of its own, not a header on the request. A transport that
|
|
38
|
+
* not sent at all: over RFC `request.stateful` picks the conversation, and
|
|
39
|
+
* Eclipse's JCo trace carries no session header either. A transport that
|
|
20
40
|
* also captured cookies would be doing that work twice, and the two copies
|
|
21
41
|
* would disagree the first time one of them was cleared.
|
|
22
42
|
*/
|
|
@@ -28,6 +48,13 @@ export interface IRfcConversation {
|
|
|
28
48
|
close(): Promise<void>;
|
|
29
49
|
call(fm: string, params: Record<string, unknown>): Promise<Record<string, any>>;
|
|
30
50
|
readonly alive: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Ends the ABAP session context and keeps the connection open
|
|
53
|
+
* (`RfcResetServerContext`, `@mcp-abap-adt/sap-rfc-lite` >= 0.2.0). Where it
|
|
54
|
+
* exists, one conversation serves the non-stateful calls, reset after each;
|
|
55
|
+
* where it does not, each such call opens a conversation of its own.
|
|
56
|
+
*/
|
|
57
|
+
resetServerContext?(): Promise<void>;
|
|
31
58
|
}
|
|
32
59
|
/** What this wire does beyond carrying the request. */
|
|
33
60
|
export interface IRfcTransportOptions {
|
|
@@ -56,6 +83,23 @@ export declare class RfcTransport implements IOnPremTransport {
|
|
|
56
83
|
/** Which system this wire is for. Read by the compiler, never at runtime. */
|
|
57
84
|
readonly system: "onprem";
|
|
58
85
|
private conversation;
|
|
86
|
+
/**
|
|
87
|
+
* Bumped by every `close()`. A call that opened a conversation of its own
|
|
88
|
+
* compares it after the logon: the wire may have been closed while that
|
|
89
|
+
* logon was under way, and a write must not go out on a wire the caller has
|
|
90
|
+
* already given back.
|
|
91
|
+
*/
|
|
92
|
+
private generation;
|
|
93
|
+
/**
|
|
94
|
+
* The conversation the non-stateful calls share when the client can reset
|
|
95
|
+
* it: opened on the first such call, reset after every call, closed with the
|
|
96
|
+
* wire. `null` until then, or after a reset failed.
|
|
97
|
+
*/
|
|
98
|
+
private stateless;
|
|
99
|
+
/** Taken by the call on `stateless` until its reset is done. */
|
|
100
|
+
private statelessBusy;
|
|
101
|
+
/** `false` once a client showed it cannot reset; then every call opens its own. */
|
|
102
|
+
private canReset;
|
|
59
103
|
/** Names the conversation, and so the ABAP session it carries. */
|
|
60
104
|
private conversationId;
|
|
61
105
|
/**
|
|
@@ -110,5 +154,31 @@ export declare class RfcTransport implements IOnPremTransport {
|
|
|
110
154
|
/** Never throws, and a repeat call finds nothing owed. */
|
|
111
155
|
close(): Promise<void>;
|
|
112
156
|
send(request: IAdtTransportRequest): Promise<IAdtTransportResponse>;
|
|
157
|
+
/**
|
|
158
|
+
* A request that is not stateful, in a fresh ABAP session.
|
|
159
|
+
*
|
|
160
|
+
* Where the client can reset (`resetServerContext`), one kept conversation
|
|
161
|
+
* serves these calls and is reset after each: a fresh context without a new
|
|
162
|
+
* logon, ~0.1 s instead of ~0.5 s on E19. Where it cannot, or while that
|
|
163
|
+
* conversation is busy with another call, the call opens a conversation of
|
|
164
|
+
* its own and closes it when it answers.
|
|
165
|
+
*
|
|
166
|
+
* A call and its reset go together: a second call on the kept conversation
|
|
167
|
+
* before the first one's reset would run in the first one's context — a
|
|
168
|
+
* package the first one saved would refuse the second one's write (PAK/058).
|
|
169
|
+
* So a concurrent call takes a conversation of its own instead of waiting.
|
|
170
|
+
*/
|
|
171
|
+
private carryOnItsOwn;
|
|
172
|
+
private carryOnKept;
|
|
173
|
+
/** A conversation opened for this one call, closed when it answers. */
|
|
174
|
+
private carryOnThrowaway;
|
|
175
|
+
/**
|
|
176
|
+
* A new conversation, logged on. The logon takes time (~0.5 s on E19); if the
|
|
177
|
+
* wire was closed meanwhile, the conversation is closed again and nothing is
|
|
178
|
+
* sent — not a PUT, not a DELETE — on a wire already given back.
|
|
179
|
+
*/
|
|
180
|
+
private openOwn;
|
|
181
|
+
private closeQuietly;
|
|
182
|
+
private carry;
|
|
113
183
|
}
|
|
114
184
|
//# sourceMappingURL=RfcTransport.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RfcTransport.d.ts","sourceRoot":"","sources":["../../src/connection/RfcTransport.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"RfcTransport.d.ts","sourceRoot":"","sources":["../../src/connection/RfcTransport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EACV,oBAAoB,EAEpB,oBAAoB,EACpB,qBAAqB,EACrB,gBAAgB,EACjB,MAAM,oBAAoB,CAAC;AAG5B,sFAAsF;AACtF,MAAM,WAAW,gBAAgB;IAC/B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,CACF,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;OAKG;IACH,kBAAkB,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AAgFD,uDAAuD;AACvD,MAAM,WAAW,oBAAoB;IACnC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAKD,qBAAa,YAAa,YAAW,gBAAgB;IAiGjD,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAlG1B,QAAQ,CAAC,IAAI,SAAS;IAEtB,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAG,QAAQ,CAAU;IAEpC,OAAO,CAAC,YAAY,CAAiC;IACrD;;;;;OAKG;IACH,OAAO,CAAC,UAAU,CAAK;IAEvB;;;;OAIG;IACH,OAAO,CAAC,SAAS,CAAiC;IAClD,gEAAgE;IAChE,OAAO,CAAC,aAAa,CAAS;IAC9B,mFAAmF;IACnF,OAAO,CAAC,QAAQ,CAAsB;IACtC,kEAAkE;IAClE,OAAO,CAAC,cAAc,CAAM;IAE5B;;;;;;OAMG;IACH,MAAM,IAAI,IAAI;IAEd,2CAA2C;IAC3C,OAAO,IAAI,IAAI;IAIf;;;;;OAKG;IACG,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IAE9D,+BAA+B;IAC/B,SAAS,IAAI,IAAI;IAIjB;;;OAGG;IACH,cAAc,IAAI,IAAI;IAEtB;;;OAGG;IACH,6EAA6E;IAC7E,kBAAkB,IAAI,OAAO;IAI7B,kBAAkB,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;IAQzC,2EAA2E;IAC3E,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAIzC;;;;OAIG;IACH,aAAa,IAAI,IAAI;IAErB;;;;;OAKG;gBAEgB,OAAO,EAAE,MAAM,gBAAgB,EAC/B,MAAM,GAAE,OAAO,GAAG,IAAW,EAC7B,OAAO,GAAE,oBAAyB;IAGrD,oDAAoD;IACpD,OAAO,CAAC,OAAO;IAcT,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAwB3B,0DAA0D;IACpD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAwBtB,IAAI,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAezE;;;;;;;;;;;;;OAaG;YACW,aAAa;YAUb,WAAW;IAqDzB,uEAAuE;YACzD,gBAAgB;IAa9B;;;;OAIG;YACW,OAAO;YAgBP,YAAY;YAQZ,KAAK;CAyKpB"}
|
|
@@ -12,18 +12,39 @@
|
|
|
12
12
|
* "invalid lock handle" on legacy systems (BASIS < 7.50) where stateful HTTP
|
|
13
13
|
* sessions are not usable.
|
|
14
14
|
*
|
|
15
|
+
* **Two conversations, the way Eclipse ADT holds them over JCo.** Its trace
|
|
16
|
+
* shows one long-lived stateful session (`JCoEnqueueSystemSession`) for `LOCK`
|
|
17
|
+
* and `UNLOCK`, and every other call — the `PUT` included — in a session of
|
|
18
|
+
* its own. Here:
|
|
19
|
+
*
|
|
20
|
+
* - the persistent conversation, opened by `open()` and kept while this wire
|
|
21
|
+
* lives, carries the requests marked `stateful` — the lock and what must
|
|
22
|
+
* share its session;
|
|
23
|
+
* - every other request opens a conversation of its own, and closes it when
|
|
24
|
+
* it answers.
|
|
25
|
+
*
|
|
26
|
+
* One conversation for everything kept whatever an ABAP program left in the
|
|
27
|
+
* session. `CL_PACKAGE` buffers its instances for the life of the session and
|
|
28
|
+
* a save leaves them changeable, so after a create or a write every further
|
|
29
|
+
* write or delete of that package answered 400 PAK/058 "Package … is already
|
|
30
|
+
* locked"; a read straight after a create answered 400 SADT_RESOURCE/007.
|
|
31
|
+
* Measured on E19 (2026-09-27): LOCK and UNLOCK on one conversation, the PUT
|
|
32
|
+
* on another — two writes in a row fail on a shared second conversation and
|
|
33
|
+
* pass on a fresh one per call. The price is a logon per non-stateful call
|
|
34
|
+
* (~0.5 s on E19, against ~0.1 s for the call itself).
|
|
35
|
+
*
|
|
15
36
|
* **What is NOT here.** Cookies, the CSRF token and the session lifecycle
|
|
16
37
|
* belong to the connection above this seam and are already in
|
|
17
38
|
* `request.headers` by the time `send()` is called. `x-sap-adt-sessiontype` is
|
|
18
|
-
* not sent at all: `request.stateful`
|
|
19
|
-
* Eclipse's JCo trace carries no session header
|
|
20
|
-
* connection of its own, not a header on the request. A transport that
|
|
39
|
+
* not sent at all: over RFC `request.stateful` picks the conversation, and
|
|
40
|
+
* Eclipse's JCo trace carries no session header either. A transport that
|
|
21
41
|
* also captured cookies would be doing that work twice, and the two copies
|
|
22
42
|
* would disagree the first time one of them was cleared.
|
|
23
43
|
*/
|
|
24
44
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
45
|
exports.RfcTransport = void 0;
|
|
26
46
|
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const statefulRequest_js_1 = require("./statefulRequest.js");
|
|
27
48
|
/**
|
|
28
49
|
* ADT exception types, mapped to what they would have been over HTTP. Standard
|
|
29
50
|
* types, returned in `<exc:exception>` XML.
|
|
@@ -101,6 +122,23 @@ class RfcTransport {
|
|
|
101
122
|
/** Which system this wire is for. Read by the compiler, never at runtime. */
|
|
102
123
|
system = 'onprem';
|
|
103
124
|
conversation = null;
|
|
125
|
+
/**
|
|
126
|
+
* Bumped by every `close()`. A call that opened a conversation of its own
|
|
127
|
+
* compares it after the logon: the wire may have been closed while that
|
|
128
|
+
* logon was under way, and a write must not go out on a wire the caller has
|
|
129
|
+
* already given back.
|
|
130
|
+
*/
|
|
131
|
+
generation = 0;
|
|
132
|
+
/**
|
|
133
|
+
* The conversation the non-stateful calls share when the client can reset
|
|
134
|
+
* it: opened on the first such call, reset after every call, closed with the
|
|
135
|
+
* wire. `null` until then, or after a reset failed.
|
|
136
|
+
*/
|
|
137
|
+
stateless = null;
|
|
138
|
+
/** Taken by the call on `stateless` until its reset is done. */
|
|
139
|
+
statelessBusy = false;
|
|
140
|
+
/** `false` once a client showed it cannot reset; then every call opens its own. */
|
|
141
|
+
canReset;
|
|
104
142
|
/** Names the conversation, and so the ABAP session it carries. */
|
|
105
143
|
conversationId = '';
|
|
106
144
|
/**
|
|
@@ -182,6 +220,7 @@ class RfcTransport {
|
|
|
182
220
|
async open() {
|
|
183
221
|
if (this.conversation?.alive)
|
|
184
222
|
return;
|
|
223
|
+
const generation = this.generation;
|
|
185
224
|
const conversation = this.connect();
|
|
186
225
|
try {
|
|
187
226
|
await conversation.open();
|
|
@@ -189,21 +228,39 @@ class RfcTransport {
|
|
|
189
228
|
catch (e) {
|
|
190
229
|
throw new Error(`Failed to open RFC connection: ${message(e)}`);
|
|
191
230
|
}
|
|
231
|
+
// close() ran while this logged on: the session it would have handed out
|
|
232
|
+
// belongs to a wire already given back, so it is closed, not kept.
|
|
233
|
+
if (generation !== this.generation) {
|
|
234
|
+
await this.closeQuietly(conversation);
|
|
235
|
+
throw new Error('RFC transport was closed while it was being opened.');
|
|
236
|
+
}
|
|
192
237
|
this.conversation = conversation;
|
|
193
238
|
// Minted here, where the session begins. A conversation opened after an
|
|
194
239
|
// earlier one closed is a DIFFERENT ABAP session, and the fingerprint has
|
|
195
240
|
// to say so — a constant would report `unchanged` across a reconnect and
|
|
196
241
|
// hide exactly the replacement the identity policy exists to catch.
|
|
197
242
|
this.conversationId = (0, node_crypto_1.randomUUID)();
|
|
198
|
-
this.logger?.debug('RFC conversation opened (stateful
|
|
243
|
+
this.logger?.debug('RFC conversation opened (the stateful one)');
|
|
199
244
|
}
|
|
200
245
|
/** Never throws, and a repeat call finds nothing owed. */
|
|
201
246
|
async close() {
|
|
247
|
+
// Everything the wire holds is given up BEFORE the first await. Closing
|
|
248
|
+
// takes time, and a send() that ran meanwhile would otherwise find the
|
|
249
|
+
// old stateful conversation still `alive` — a lock request carried on a
|
|
250
|
+
// session already being given back — or open a stateless one of its own
|
|
251
|
+
// and send a write after close(). With both references gone and the
|
|
252
|
+
// generation moved on, such a send() is refused as "not open", and a
|
|
253
|
+
// logon still under way sees the change and sends nothing.
|
|
254
|
+
this.generation += 1;
|
|
255
|
+
const stateless = this.stateless;
|
|
202
256
|
const conversation = this.conversation;
|
|
203
|
-
|
|
204
|
-
return;
|
|
257
|
+
this.stateless = null;
|
|
205
258
|
this.conversation = null;
|
|
206
259
|
this.conversationId = '';
|
|
260
|
+
if (stateless)
|
|
261
|
+
await this.closeQuietly(stateless);
|
|
262
|
+
if (!conversation)
|
|
263
|
+
return;
|
|
207
264
|
try {
|
|
208
265
|
await conversation.close();
|
|
209
266
|
this.logger?.debug('RFC conversation closed');
|
|
@@ -216,6 +273,123 @@ class RfcTransport {
|
|
|
216
273
|
if (!this.conversation?.alive) {
|
|
217
274
|
throw new Error('RFC transport is not open. Call connect() first.');
|
|
218
275
|
}
|
|
276
|
+
// The same verdict HTTP takes: the flag the connection sets, or a session
|
|
277
|
+
// header the caller wrote itself. A caller that asks for the lock's
|
|
278
|
+
// session either way has to land on the conversation that holds it.
|
|
279
|
+
// Taken once, here: every await below compares against it, and a call
|
|
280
|
+
// that finds the wire closed since it began sends nothing.
|
|
281
|
+
const generation = this.generation;
|
|
282
|
+
return (0, statefulRequest_js_1.isStatefulRequest)(request)
|
|
283
|
+
? this.carry(request, this.conversation, generation)
|
|
284
|
+
: this.carryOnItsOwn(request, generation);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* A request that is not stateful, in a fresh ABAP session.
|
|
288
|
+
*
|
|
289
|
+
* Where the client can reset (`resetServerContext`), one kept conversation
|
|
290
|
+
* serves these calls and is reset after each: a fresh context without a new
|
|
291
|
+
* logon, ~0.1 s instead of ~0.5 s on E19. Where it cannot, or while that
|
|
292
|
+
* conversation is busy with another call, the call opens a conversation of
|
|
293
|
+
* its own and closes it when it answers.
|
|
294
|
+
*
|
|
295
|
+
* A call and its reset go together: a second call on the kept conversation
|
|
296
|
+
* before the first one's reset would run in the first one's context — a
|
|
297
|
+
* package the first one saved would refuse the second one's write (PAK/058).
|
|
298
|
+
* So a concurrent call takes a conversation of its own instead of waiting.
|
|
299
|
+
*/
|
|
300
|
+
async carryOnItsOwn(request, generation) {
|
|
301
|
+
if (this.canReset !== false && !this.statelessBusy) {
|
|
302
|
+
return this.carryOnKept(request, generation);
|
|
303
|
+
}
|
|
304
|
+
return this.carryOnThrowaway(request, generation);
|
|
305
|
+
}
|
|
306
|
+
async carryOnKept(request, generation) {
|
|
307
|
+
this.statelessBusy = true;
|
|
308
|
+
try {
|
|
309
|
+
if (!this.stateless?.alive) {
|
|
310
|
+
const opened = await this.openOwn(generation);
|
|
311
|
+
// Checked again after the await: close() may have run since openOwn()
|
|
312
|
+
// checked, and a conversation of a closed wire must not become the
|
|
313
|
+
// kept one of the next.
|
|
314
|
+
if (generation !== this.generation) {
|
|
315
|
+
await this.closeQuietly(opened);
|
|
316
|
+
throw new Error('RFC transport was closed while the call was being opened; nothing was sent.');
|
|
317
|
+
}
|
|
318
|
+
if (typeof opened.resetServerContext !== 'function') {
|
|
319
|
+
// An older client: it cannot be reset, so it is used once and closed,
|
|
320
|
+
// and every later call opens its own.
|
|
321
|
+
this.canReset = false;
|
|
322
|
+
try {
|
|
323
|
+
return await this.carry(request, opened, generation);
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
await this.closeQuietly(opened);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
this.canReset = true;
|
|
330
|
+
this.stateless = opened;
|
|
331
|
+
this.logger?.debug('RFC stateless conversation opened (reset per call)');
|
|
332
|
+
}
|
|
333
|
+
const kept = this.stateless;
|
|
334
|
+
try {
|
|
335
|
+
return await this.carry(request, kept, generation);
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
try {
|
|
339
|
+
await kept.resetServerContext?.();
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
// A context that could not be reset must not serve the next call.
|
|
343
|
+
this.logger?.debug(`RFC reset failed, dropping the conversation: ${message(e)}`);
|
|
344
|
+
if (this.stateless === kept)
|
|
345
|
+
this.stateless = null;
|
|
346
|
+
await this.closeQuietly(kept);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
this.statelessBusy = false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** A conversation opened for this one call, closed when it answers. */
|
|
355
|
+
async carryOnThrowaway(request, generation) {
|
|
356
|
+
const own = await this.openOwn(generation);
|
|
357
|
+
this.logger?.debug('RFC conversation opened for one stateless call');
|
|
358
|
+
try {
|
|
359
|
+
return await this.carry(request, own, generation);
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
await this.closeQuietly(own);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* A new conversation, logged on. The logon takes time (~0.5 s on E19); if the
|
|
367
|
+
* wire was closed meanwhile, the conversation is closed again and nothing is
|
|
368
|
+
* sent — not a PUT, not a DELETE — on a wire already given back.
|
|
369
|
+
*/
|
|
370
|
+
async openOwn(generation) {
|
|
371
|
+
const own = this.connect();
|
|
372
|
+
try {
|
|
373
|
+
await own.open();
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
throw new Error(`Failed to open RFC connection: ${message(e)}`);
|
|
377
|
+
}
|
|
378
|
+
if (generation !== this.generation || !this.conversation?.alive) {
|
|
379
|
+
await this.closeQuietly(own);
|
|
380
|
+
throw new Error('RFC transport was closed while the call was being opened; nothing was sent.');
|
|
381
|
+
}
|
|
382
|
+
return own;
|
|
383
|
+
}
|
|
384
|
+
async closeQuietly(conversation) {
|
|
385
|
+
try {
|
|
386
|
+
await conversation.close();
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
this.logger?.debug(`RFC close error: ${message(e)}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async carry(request, conversation, generation) {
|
|
219
393
|
const method = request.method.toUpperCase();
|
|
220
394
|
// HTTP clients serialise `params`; RFC has no such step, so they go into
|
|
221
395
|
// the URI here or they do not travel at all.
|
|
@@ -271,9 +445,14 @@ class RfcTransport {
|
|
|
271
445
|
// mid-flight is precisely what the connection emulates over HTTP, where
|
|
272
446
|
// a critical section raises the caller's timeout to a ten-minute ceiling so
|
|
273
447
|
// an abort cannot orphan a lock handle. Here that comes free.
|
|
448
|
+
// The last point before anything leaves: nothing awaits between this
|
|
449
|
+
// check and the call, so a close() that ran at any await above stops it.
|
|
450
|
+
if (generation !== this.generation) {
|
|
451
|
+
throw new Error('RFC transport was closed before the call was sent; nothing was sent.');
|
|
452
|
+
}
|
|
274
453
|
let raw;
|
|
275
454
|
try {
|
|
276
|
-
raw = await
|
|
455
|
+
raw = await conversation.call('SADT_REST_RFC_ENDPOINT', {
|
|
277
456
|
REQUEST: {
|
|
278
457
|
REQUEST_LINE: {
|
|
279
458
|
METHOD: method,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a request runs in the stateful session — the one a lock is taken and
|
|
3
|
+
* released in.
|
|
4
|
+
*
|
|
5
|
+
* One verdict for every wire. HTTP expresses it as a header and the context
|
|
6
|
+
* cookie, RFC as the conversation it picks; if the two wires decided it
|
|
7
|
+
* differently, a caller that asked for the session one way would keep its lock
|
|
8
|
+
* on one wire and lose it on the other.
|
|
9
|
+
*/
|
|
10
|
+
import type { IAdtTransportRequest } from './IAdtTransport.js';
|
|
11
|
+
/** The header that asks SAP for the stateful session over HTTP. */
|
|
12
|
+
export declare const SESSION_TYPE_HEADER = "x-sap-adt-sessiontype";
|
|
13
|
+
/**
|
|
14
|
+
* Marked so by the connection, or — kept for callers that still write it
|
|
15
|
+
* themselves — carrying the session header already. Taken on the request as
|
|
16
|
+
* the caller made it, before a wire rewrites it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isStatefulRequest(request: IAdtTransportRequest): boolean;
|
|
19
|
+
//# sourceMappingURL=statefulRequest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statefulRequest.d.ts","sourceRoot":"","sources":["../../src/connection/statefulRequest.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAE/D,mEAAmE;AACnE,eAAO,MAAM,mBAAmB,0BAA0B,CAAC;AAE3D;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAaxE"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SESSION_TYPE_HEADER = void 0;
|
|
4
|
+
exports.isStatefulRequest = isStatefulRequest;
|
|
5
|
+
/** The header that asks SAP for the stateful session over HTTP. */
|
|
6
|
+
exports.SESSION_TYPE_HEADER = 'x-sap-adt-sessiontype';
|
|
7
|
+
/**
|
|
8
|
+
* Marked so by the connection, or — kept for callers that still write it
|
|
9
|
+
* themselves — carrying the session header already. Taken on the request as
|
|
10
|
+
* the caller made it, before a wire rewrites it.
|
|
11
|
+
*/
|
|
12
|
+
function isStatefulRequest(request) {
|
|
13
|
+
if (request.stateful)
|
|
14
|
+
return true;
|
|
15
|
+
const headers = request.headers;
|
|
16
|
+
if (!headers)
|
|
17
|
+
return false;
|
|
18
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
19
|
+
if (name.toLowerCase() === exports.SESSION_TYPE_HEADER &&
|
|
20
|
+
String(value).toLowerCase() === 'stateful') {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
@@ -49,7 +49,7 @@ The filter also applies to cookies supplied in request headers and retries.
|
|
|
49
49
|
| --- | --- |
|
|
50
50
|
| HTTP (on-prem / cloud) | Sends `x-sap-adt-sessiontype: stateful` and the context cookie, when held |
|
|
51
51
|
| Legacy on-prem HTTP | Keeps the context cookie but omits the session-type header |
|
|
52
|
-
| RFC | Uses
|
|
52
|
+
| RFC | Uses the persistent conversation for stateful requests; since 9.3.4 resets a kept stateless conversation between calls when supported |
|
|
53
53
|
|
|
54
54
|
A caller-supplied stateful header is also recognized, including by the legacy
|
|
55
55
|
HTTP transport before it removes that header. Custom `IAdtTransport`
|
|
@@ -57,6 +57,29 @@ implementations must read `request.stateful`; the connection no longer adds
|
|
|
57
57
|
`x-sap-adt-sessiontype` itself. The cookie fix was verified on E19 and E98;
|
|
58
58
|
BASIS 7.40 and ABAP Cloud were not integration-tested for this change.
|
|
59
59
|
|
|
60
|
+
## RFC conversation isolation (9.3.2–9.3.4)
|
|
61
|
+
|
|
62
|
+
Stateful requests, including LOCK and UNLOCK, share the conversation opened
|
|
63
|
+
by `connect()`. Since 9.3.4, `sap-rfc-lite` 0.2.0 and newer let stateless calls
|
|
64
|
+
reuse a second conversation: the transport resets its ABAP server context after
|
|
65
|
+
each call, keeping the RFC logon open. With older clients (0.1.x), or when a
|
|
66
|
+
resettable conversation is busy, each stateless call gets a separate temporary
|
|
67
|
+
conversation. Both `setSessionType('stateful')` and a caller-supplied stateful
|
|
68
|
+
session header select the persistent conversation.
|
|
69
|
+
|
|
70
|
+
Previously all RFC requests shared one ABAP session. On E19, saving a package
|
|
71
|
+
left state in `CL_PACKAGE`'s instance buffer: later writes or deletes failed
|
|
72
|
+
with **400 PAK/058, “Package … is already locked”**. Reading immediately after
|
|
73
|
+
creation could fail with **400 SADT_RESOURCE/007**. With separate conversations,
|
|
74
|
+
create, read, two lock/PUT/unlock rounds and delete passed on one connection.
|
|
75
|
+
|
|
76
|
+
When using `sap-rfc-lite` 0.1.x, each stateless call pays for an RFC logon:
|
|
77
|
+
about 0.5 s on E19, in addition to about 0.1 s for the call. With 0.2.0+, a
|
|
78
|
+
resettable call took about 0.24 s total on E19. Direct connector users must mark
|
|
79
|
+
LOCK and UNLOCK stateful to retain their conversation. If disconnect occurs
|
|
80
|
+
while a temporary conversation is logging on, it is closed without sending
|
|
81
|
+
the request; reconnect does not revive that pending request.
|
|
82
|
+
|
|
60
83
|
## Enabling Stateful Sessions
|
|
61
84
|
|
|
62
85
|
Every example below builds on this much, so it is stated once:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcp-abap-adt/connection",
|
|
3
|
-
"version": "9.3.
|
|
3
|
+
"version": "9.3.4",
|
|
4
4
|
"description": "ABAP connection layer for MCP ABAP ADT server",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"open": "^11.0.0"
|
|
64
64
|
},
|
|
65
65
|
"optionalDependencies": {
|
|
66
|
-
"@mcp-abap-adt/sap-rfc-lite": "^0.1.0"
|
|
66
|
+
"@mcp-abap-adt/sap-rfc-lite": "^0.1.0 || ^0.2.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@biomejs/biome": "^2.3.14",
|