@contrail/flexplm 1.7.7-alpha.24a6b7d → 1.7.7-alpha.279b642

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.
@@ -1,3 +1,15 @@
1
+ export interface SessionReuseConfig {
2
+ enabled?: boolean | string;
3
+ ttlSeconds?: number | string;
4
+ cookieName?: string;
5
+ retryStatuses?: number | number[] | string;
6
+ }
7
+ export interface FlexPLMConnectConfig {
8
+ staticHeaders?: {
9
+ [key: string]: string;
10
+ };
11
+ sessionReuse?: SessionReuseConfig;
12
+ }
1
13
  export interface FCConfig {
2
14
  apiKey?: string;
3
15
  orgSlug?: string;
@@ -5,7 +17,7 @@ export interface FCConfig {
5
17
  configFile?: string;
6
18
  eventObjectClassParam?: string;
7
19
  eventFlexTypePathParam?: string;
8
- flexplmConnect?: any;
20
+ flexplmConnect?: FlexPLMConnectConfig;
9
21
  taskId?: string;
10
22
  apiHost: string;
11
23
  userName(): string;
@@ -1,24 +1,16 @@
1
1
  import { FCConfig, FlexPLMResponseData, PayloadType } from '../interfaces/interfaces';
2
2
  export declare class FlexPLMConnect {
3
3
  private config;
4
+ private sessions;
4
5
  private vibeEventEndpoint;
5
6
  private staticHeaders;
6
7
  payloadSendAsArray: boolean;
7
8
  constructor(_config: FCConfig, endPoint?: any, payloadAsArray?: any);
8
- private get sessionOptions();
9
- private getSessionKey;
10
- private getCachedSession;
11
- private storeSession;
12
- private clearSession;
13
- /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
14
- private extractSessionCookie;
15
- private cookieHeader;
9
+ /** Releases the connection of a rejected response before it is replaced by a retry. */
10
+ private discardBody;
16
11
  private getRequestOptions;
17
- getCSRF(getNewSession?: boolean): Promise<{
18
- nonce_key: any;
19
- nonce: any;
20
- sessionCookie: string;
21
- }>;
12
+ getCSRF(getNewSession?: boolean, rejectedCookie?: string): any;
13
+ private fetchCSRF;
22
14
  sendRequest(csrf: any, payload: any): Promise<Response>;
23
15
  protected processRequest(payload: any): Promise<FlexPLMResponseData>;
24
16
  sendToFlexPLM(payload: PayloadType): Promise<FlexPLMResponseData>;
@@ -2,15 +2,14 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FlexPLMConnect = void 0;
4
4
  const app_framework_1 = require("@contrail/app-framework");
5
- // Module-level on purpose: a new FlexPLMConnect is constructed for every event. Declaring for Session reusing use case.
6
- const sessionCache = new Map();
7
- // Added for Session reuse - ET-390 - Ends
5
+ const flexplm_session_manager_1 = require("./flexplm-session-manager");
8
6
  class FlexPLMConnect {
9
7
  constructor(_config, endPoint = undefined, payloadAsArray = undefined) {
10
8
  this.vibeEventEndpoint = '';
11
9
  this.staticHeaders = undefined;
12
10
  this.payloadSendAsArray = true;
13
11
  this.config = _config;
12
+ this.sessions = new flexplm_session_manager_1.FlexPLMSessionManager(_config);
14
13
  this.vibeEventEndpoint = (endPoint)
15
14
  ? endPoint
16
15
  : this.config.vibeEventEndpoint;
@@ -21,87 +20,18 @@ class FlexPLMConnect {
21
20
  this.staticHeaders = this.config?.flexplmConnect?.staticHeaders;
22
21
  }
23
22
  }
24
- /////////////////////////////////////////////////////////////////////////////
25
- ///////// ET-390 - Session reuse: start
26
- /////////////////////////////////////////////////////////////////////////////
27
- /*
28
- private get sessionOptions() {
29
- const sessReuseBool = this.config?.flexplmConnect?.sessionReuse;
30
- console.info('sessReuseBool value is --> ' + sessReuseBool);
31
- console.info('enabled value is --> ' + sessReuseBool?.enabled);
32
- return {
33
- enabled: sessReuseBool?.enabled === true || String(sessReuseBool?.enabled).toLowerCase() === 'true',
34
- ttlMs: (sessReuseBool?.ttlSeconds ?? 900) * 1000,
35
- cookieName: sessReuseBool?.cookieName ?? 'JSESSIONID',
36
- retryStatuses: (sessReuseBool?.retryStatuses ?? [401, 403]) as number[],
37
- };
38
- }
39
- */
40
- get sessionOptions() {
41
- const cfg = this.config ?? {};
42
- const sessReuseEntries = (name) => cfg[`flexplmConnect.sessionReuse.${name}`];
43
- const enabled = sessReuseEntries('enabled');
44
- return {
45
- enabled: enabled === true || String(enabled).toLowerCase() === 'true',
46
- ttlMs: (sessReuseEntries('ttlSeconds') ?? 900) * 1000,
47
- cookieName: sessReuseEntries('cookieName') ?? 'JSESSIONID',
48
- retryStatuses: (sessReuseEntries('retryStatuses') ?? [401, 403]),
49
- };
50
- }
51
- getSessionKey() {
52
- return [this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment].join('|');
53
- }
54
- getCachedSession() {
55
- if (!this.sessionOptions.enabled)
56
- return undefined;
57
- const key = this.getSessionKey();
58
- const cachedSessValue = sessionCache.get(key);
59
- if (!cachedSessValue)
60
- return undefined;
61
- if (cachedSessValue.expiresAt <= Date.now()) {
62
- sessionCache.delete(key);
63
- return undefined;
64
- }
65
- return cachedSessValue.cookie;
66
- }
67
- storeSession(cookie) {
68
- // sliding expiry: Windchill's session timeout is also sliding
69
- sessionCache.set(this.getSessionKey(), { cookie, expiresAt: Date.now() + this.sessionOptions.ttlMs });
70
- }
71
- clearSession() {
72
- sessionCache.delete(this.getSessionKey());
73
- }
74
- /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
75
- extractSessionCookie(response) {
76
- const respHeader = response?.headers;
77
- if (!respHeader)
78
- return undefined;
79
- let raw = [];
80
- if (typeof respHeader.getSetCookie === 'function') {
81
- raw = respHeader.getSetCookie();
82
- }
83
- else if (typeof respHeader.get === 'function' && respHeader.get('set-cookie')) {
84
- raw = [respHeader.get('set-cookie')];
23
+ // ET-390 - Session reuse
24
+ /** Releases the connection of a rejected response before it is replaced by a retry. */
25
+ async discardBody(response) {
26
+ try {
27
+ if (typeof response?.body?.cancel === 'function')
28
+ await response.body.cancel();
85
29
  }
86
- const name = this.sessionOptions.cookieName;
87
- const regexFilter = new RegExp('(?:^|[,\\s])' + name + '=([^;,\\s]+)', 'g');
88
- let jSession;
89
- for (const line of raw) {
90
- let cookieNameMatcher = regexFilter.exec(line);
91
- while (cookieNameMatcher !== null) {
92
- jSession = cookieNameMatcher[1];
93
- cookieNameMatcher = regexFilter.exec(line);
94
- }
30
+ catch (e) {
31
+ // best effort: nothing to do if the body is already consumed or locked
95
32
  }
96
- return jSession;
97
- }
98
- cookieHeader(sessionCookie) {
99
- return sessionCookie ? `${this.sessionOptions.cookieName}=${sessionCookie}` : undefined;
100
33
  }
101
34
  /////////////////////////////////////////////////////////////////////////////
102
- ///////// ET-390 - Session reuse: end
103
- /////////////////////////////////////////////////////////////////////////////
104
- /////////////////////////////////////////////////////////////////////////////
105
35
  ///////// Custom getRequestOptions: start
106
36
  /////////////////////////////////////////////////////////////////////////////
107
37
  // ET-390 - adding a new parameter sessionCookie to the getRequestOptions function
@@ -118,7 +48,7 @@ class FlexPLMConnect {
118
48
  Object.assign(csrfOptions.headers, this.staticHeaders);
119
49
  }
120
50
  // Added for Session reuse - ET-390 - Starts
121
- const cookie = this.cookieHeader(sessionCookie);
51
+ const cookie = this.sessions.cookieHeader(sessionCookie);
122
52
  if (cookie) {
123
53
  csrfOptions.headers['Cookie'] = cookie;
124
54
  }
@@ -139,26 +69,48 @@ class FlexPLMConnect {
139
69
  /////////////////////////////////////////////////////////////////////////////
140
70
  // ET-390 - adding a new parameter getNewSession to the getCSRF function
141
71
  // Whenever a getCSRF is called without getNewSession, the default value of false will be used
142
- async getCSRF(getNewSession = false) {
72
+ // rejectedCookie: the cookie that was rejected by the caller, so a refresh already done by a concurrent event is not thrown away
73
+ async getCSRF(getNewSession = false, rejectedCookie) {
74
+ // Added for Session reuse - ET-390 - Starts
75
+ if (!this.sessions.options.enabled)
76
+ return this.fetchCSRF(undefined);
77
+ for (let wait = this.sessions.waitForInFlightAcquisition(); wait; wait = this.sessions.waitForInFlightAcquisition())
78
+ await wait;
79
+ if (getNewSession) {
80
+ const current = this.sessions.getCachedSession();
81
+ if (rejectedCookie === undefined || !current || current === rejectedCookie)
82
+ this.sessions.clearSession();
83
+ }
84
+ const cached = this.sessions.getCachedSession();
85
+ if (cached) {
86
+ try {
87
+ return await this.fetchCSRF(cached);
88
+ }
89
+ catch (e) {
90
+ if (!(e instanceof flexplm_session_manager_1.SessionRejectedError))
91
+ throw e;
92
+ // if the cached cookie is rejected (401/403), drop it and acquire a new session (serialized)
93
+ if (this.sessions.getCachedSession() === cached)
94
+ this.sessions.clearSession();
95
+ return this.getCSRF();
96
+ }
97
+ }
98
+ return this.sessions.runColdAcquisition(() => this.fetchCSRF(undefined));
99
+ // Added for Session reuse - ET-390 - Ends
100
+ }
101
+ async fetchCSRF(sentCookie) {
143
102
  const urlContext = this.config.urlContext;
144
103
  const csrfEndpoint = this.config.csrfEndpoint;
145
104
  const csrfURL = this.config.apiHost + urlContext + csrfEndpoint;
146
105
  console.info('csrfURL: ' + csrfURL);
147
- // Added for Session reuse - ET-390 - Starts
148
- if (getNewSession)
149
- this.clearSession();
150
- let sentCookie = this.getCachedSession();
151
106
  // getRequestOptions will be called with additional sentCookie parameter
152
107
  const csrfOptions = this.getRequestOptions('GET', sentCookie);
153
- // Added for Session reuse - ET-390 - Ends
154
- let response = await fetch(csrfURL, csrfOptions);
108
+ const response = await fetch(csrfURL, csrfOptions);
155
109
  // Added for Session reuse - ET-390 - Starts
156
- // if the response status is 401 or 403, then get a new session id
157
- if (sentCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
110
+ if (sentCookie && this.sessions.options.retryStatuses.includes(response.status)) {
158
111
  console.warn('Cached FlexPLM session rejected on csrf call (status ' + response.status + '). Retrying with a new session.');
159
- this.clearSession();
160
- sentCookie = undefined;
161
- response = await fetch(csrfURL, this.getRequestOptions('GET'));
112
+ await this.discardBody(response);
113
+ throw new flexplm_session_manager_1.SessionRejectedError();
162
114
  }
163
115
  // Added for Session reuse - ET-390 - Ends
164
116
  if (response.status >= 300) {
@@ -172,10 +124,10 @@ class FlexPLMConnect {
172
124
  // Added for Session reuse - ET-390 - Starts
173
125
  // If the cookie we sent was invalid, Windchill answers with a fresh Set-Cookie --> replace the cache.
174
126
  let sessionCookie = sentCookie;
175
- if (this.sessionOptions.enabled) {
176
- sessionCookie = this.extractSessionCookie(response) ?? sentCookie;
127
+ if (this.sessions.options.enabled) {
128
+ sessionCookie = this.sessions.extractSessionCookie(response) ?? sentCookie;
177
129
  if (sessionCookie)
178
- this.storeSession(sessionCookie);
130
+ this.sessions.storeSession(sessionCookie);
179
131
  }
180
132
  // Added for Session reuse - ET-390 - Ends
181
133
  try {
@@ -239,9 +191,10 @@ class FlexPLMConnect {
239
191
  /// Added for Session reuse - ET-390 - Starts
240
192
  // const response = await this.sendRequest(csrf, payload);
241
193
  let response = await this.sendRequest(csrf, payload);
242
- if (csrf.sessionCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
194
+ if (csrf.sessionCookie && this.sessions.options.retryStatuses.includes(response.status)) {
243
195
  console.warn('FlexPLM rejected session/nonce (status ' + response.status + '). Getting a new session and retrying once.');
244
- csrf = await this.getCSRF(true);
196
+ await this.discardBody(response);
197
+ csrf = await this.getCSRF(true, csrf.sessionCookie);
245
198
  response = await this.sendRequest(csrf, payload);
246
199
  }
247
200
  // Added for Session reuse - ET-390 - Ends
@@ -298,17 +251,32 @@ class FlexPLMConnect {
298
251
  const path = urlPath || ('/servlet/rest' + this.vibeEventEndpoint);
299
252
  const vibeEventsURL = this.config.apiHost + urlContext + path;
300
253
  // Added for Session reuse - ET-390 - Starts
301
- const cached = this.getCachedSession();
302
- const csrfOptions = this.getRequestOptions('GET', cached);
303
- let response = await fetch(vibeEventsURL, csrfOptions);
304
- if (cached && this.sessionOptions.retryStatuses.includes(response.status)) {
305
- this.clearSession();
306
- response = await fetch(vibeEventsURL, this.getRequestOptions('GET'));
307
- }
308
- if (this.sessionOptions.enabled) {
309
- const cachedCookie = this.extractSessionCookie(response) ?? cached;
310
- if (cachedCookie)
311
- this.storeSession(cachedCookie);
254
+ const attempt = async () => {
255
+ if (!this.sessions.options.enabled) {
256
+ return { response: await fetch(vibeEventsURL, this.getRequestOptions('GET')), sent: undefined };
257
+ }
258
+ for (let wait = this.sessions.waitForInFlightAcquisition(); wait; wait = this.sessions.waitForInFlightAcquisition())
259
+ await wait;
260
+ const sent = this.sessions.getCachedSession();
261
+ const call = async () => {
262
+ const res = await fetch(vibeEventsURL, this.getRequestOptions('GET', sent));
263
+ // A rejected cookie must not be stored again; only keep a cookie that was accepted or newly issued.
264
+ if (!(sent && this.sessions.options.retryStatuses.includes(res.status))) {
265
+ const cookie = this.sessions.extractSessionCookie(res) ?? sent;
266
+ if (cookie)
267
+ this.sessions.storeSession(cookie);
268
+ }
269
+ return { response: res, sent };
270
+ };
271
+ return sent ? call() : this.sessions.runColdAcquisition(call);
272
+ };
273
+ const first = await attempt();
274
+ let response = first.response;
275
+ if (first.sent && this.sessions.options.retryStatuses.includes(response.status)) {
276
+ await this.discardBody(response);
277
+ if (this.sessions.getCachedSession() === first.sent)
278
+ this.sessions.clearSession();
279
+ response = (await attempt()).response;
312
280
  }
313
281
  // Added for Session reuse - ET-390 - Ends
314
282
  if (response.status >= 300) {
@@ -39,12 +39,8 @@ const resp = (status, setCookie = [], json = csrfBody) => ({
39
39
  });
40
40
  let userCounter = 0;
41
41
  const uniqueUser = () => 'sess-user-' + (++userCounter);
42
- // The connector config is flat: keys look like 'flexplmConnect.sessionReuse.enabled'
43
- const sessionConfig = (user, sessionReuse = { enabled: true }) => {
44
- const flat = {};
45
- Object.entries(sessionReuse).forEach(([k, v]) => { flat[`flexplmConnect.sessionReuse.${k}`] = v; });
46
- return createConfig({ userName: () => user, ...flat });
47
- };
42
+ // Session options are nested: { flexplmConnect: { sessionReuse: { enabled, ... } } }
43
+ const sessionConfig = (user, sessionReuse = { enabled: true }) => createConfig({ userName: () => user, flexplmConnect: { sessionReuse } });
48
44
  const fetchMock = () => global.fetch;
49
45
  const callHeaders = (i) => fetchMock().mock.calls[i][1].headers;
50
46
  const callMethod = (i) => fetchMock().mock.calls[i][1].method;
@@ -156,7 +152,7 @@ describe('FlexPLMConnect session reuse', () => {
156
152
  expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
157
153
  expect(callHeaders(1).CSRF_NONCE).toBe('n1');
158
154
  });
159
- it('reuses the cached session on the next event (new FlexPLMConnect instance)', async () => {
155
+ it('should reuse the cached session on the next event (new FlexPLMConnect instance)', async () => {
160
156
  const u = uniqueUser();
161
157
  fetchMock()
162
158
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1; Path=/'])) // event 1 GET
@@ -168,7 +164,7 @@ describe('FlexPLMConnect session reuse', () => {
168
164
  expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1'); // GET reuses it
169
165
  expect(callHeaders(3).Cookie).toBe('JSESSIONID=abc.tomcat1'); // POST reuses it
170
166
  });
171
- it('replaces the cached session when Windchill answers the GET with a new Set-Cookie', async () => {
167
+ it('should replace the cached session when Windchill answers the GET with a new Set-Cookie', async () => {
172
168
  const u = uniqueUser();
173
169
  fetchMock()
174
170
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1; Path=/']))
@@ -185,7 +181,7 @@ describe('FlexPLMConnect session reuse', () => {
185
181
  expect(callHeaders(3).Cookie).toBe('JSESSIONID=new.tomcat1'); // POST uses the replacement
186
182
  expect(callHeaders(4).Cookie).toBe('JSESSIONID=new.tomcat1'); // event 3 GET sends the replacement
187
183
  });
188
- it('retries once with a new session when the POST is rejected with 403', async () => {
184
+ it('should retry once with a new session when the POST is rejected with 403', async () => {
189
185
  const u = uniqueUser();
190
186
  fetchMock()
191
187
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1; Path=/'])) // GET
@@ -199,7 +195,7 @@ describe('FlexPLMConnect session reuse', () => {
199
195
  expect(result.status).toBe(200);
200
196
  expect(result.data).toEqual({ ok: true });
201
197
  });
202
- it('also retries on 401', async () => {
198
+ it('should also retry on 401', async () => {
203
199
  const u = uniqueUser();
204
200
  fetchMock()
205
201
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
@@ -209,7 +205,7 @@ describe('FlexPLMConnect session reuse', () => {
209
205
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
210
206
  expect(fetchMock()).toHaveBeenCalledTimes(4);
211
207
  });
212
- it('does not loop: a second 403 after the retry is surfaced as an error', async () => {
208
+ it('should not loop: a second 403 after the retry is surfaced as an error', async () => {
213
209
  const u = uniqueUser();
214
210
  fetchMock()
215
211
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
@@ -220,7 +216,7 @@ describe('FlexPLMConnect session reuse', () => {
220
216
  .rejects.toThrow('Error sending data to FlexPLM:status: 403');
221
217
  expect(fetchMock()).toHaveBeenCalledTimes(4);
222
218
  });
223
- it('does not retry on non-auth errors such as 500 (event may already be processed)', async () => {
219
+ it('should not retry on non-auth errors such as 500 (event may already be processed)', async () => {
224
220
  const u = uniqueUser();
225
221
  fetchMock()
226
222
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
@@ -229,7 +225,7 @@ describe('FlexPLMConnect session reuse', () => {
229
225
  .rejects.toThrow('Error sending data to FlexPLM:status: 500');
230
226
  expect(fetchMock()).toHaveBeenCalledTimes(2);
231
227
  });
232
- it('retries the csrf GET without a cookie when the cached session is rejected there', async () => {
228
+ it('should retry the csrf GET without a cookie when the cached session is rejected there', async () => {
233
229
  const u = uniqueUser();
234
230
  fetchMock()
235
231
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1'])) // event 1 GET
@@ -243,7 +239,7 @@ describe('FlexPLMConnect session reuse', () => {
243
239
  expect(callHeaders(3).Cookie).toBeUndefined();
244
240
  expect(callHeaders(4).Cookie).toBe('JSESSIONID=new.tomcat1');
245
241
  });
246
- it('expires the cached session after ttlSeconds', async () => {
242
+ it('should expire the cached session after ttlSeconds', async () => {
247
243
  const u = uniqueUser();
248
244
  const realNow = Date.now();
249
245
  const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
@@ -260,7 +256,7 @@ describe('FlexPLMConnect session reuse', () => {
260
256
  expect(callHeaders(3).Cookie).toBe('JSESSIONID=def.tomcat1');
261
257
  nowSpy.mockRestore();
262
258
  });
263
- it('uses the last JSESSIONID when several Set-Cookie lines are returned', async () => {
259
+ it('should use the last JSESSIONID when several Set-Cookie lines are returned', async () => {
264
260
  const u = uniqueUser();
265
261
  fetchMock()
266
262
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=first.tomcat1; Path=/', 'OTHER=x; Path=/', 'JSESSIONID=second.tomcat1; Path=/']))
@@ -268,7 +264,7 @@ describe('FlexPLMConnect session reuse', () => {
268
264
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
269
265
  expect(callHeaders(1).Cookie).toBe('JSESSIONID=second.tomcat1');
270
266
  });
271
- it('falls back to headers.get("set-cookie") when getSetCookie is not available', async () => {
267
+ it('should fall back to headers.get("set-cookie") when getSetCookie is not available', async () => {
272
268
  const u = uniqueUser();
273
269
  const legacy = {
274
270
  status: 200,
@@ -280,7 +276,7 @@ describe('FlexPLMConnect session reuse', () => {
280
276
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
281
277
  expect(callHeaders(1).Cookie).toBe('JSESSIONID=legacy.tomcat1');
282
278
  });
283
- it('honours a custom cookieName', async () => {
279
+ it('should honour a custom cookieName', async () => {
284
280
  const u = uniqueUser();
285
281
  fetchMock()
286
282
  .mockResolvedValueOnce(resp(200, ['MYSESSION=zzz; Path=/', 'JSESSIONID=ignored; Path=/']))
@@ -288,7 +284,7 @@ describe('FlexPLMConnect session reuse', () => {
288
284
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'MYSESSION' })).sendToFlexPLM(payload);
289
285
  expect(callHeaders(1).Cookie).toBe('MYSESSION=zzz');
290
286
  });
291
- it('accepts enabled as the string "true"', async () => {
287
+ it('should accept enabled as the string "true"', async () => {
292
288
  const u = uniqueUser();
293
289
  fetchMock()
294
290
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
@@ -296,7 +292,39 @@ describe('FlexPLMConnect session reuse', () => {
296
292
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: 'true' })).sendToFlexPLM(payload);
297
293
  expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
298
294
  });
299
- it('keeps separate sessions per user', async () => {
295
+ it('should evict expired sessions of other users when a new session is stored', async () => {
296
+ const userA = uniqueUser();
297
+ const userB = uniqueUser();
298
+ const realNow = Date.now();
299
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
300
+ fetchMock()
301
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=a.tomcat1'])) // A GET
302
+ .mockResolvedValueOnce(resp(200, [], {})) // A POST
303
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=b.tomcat1'])) // B GET
304
+ .mockResolvedValueOnce(resp(200, [], {})) // B POST
305
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=a2.tomcat1'])) // A GET again
306
+ .mockResolvedValueOnce(resp(200, [], {}));
307
+ const cfg = (u) => sessionConfig(u, { enabled: true, ttlSeconds: 1 });
308
+ await new flexplm_connect_1.FlexPLMConnect(cfg(userA)).sendToFlexPLM(payload);
309
+ nowSpy.mockReturnValue(realNow + 2000); // A has expired
310
+ await new flexplm_connect_1.FlexPLMConnect(cfg(userB)).sendToFlexPLM(payload); // storing B sweeps A
311
+ nowSpy.mockReturnValue(realNow); // rewind: only a swept A has no entry left
312
+ await new flexplm_connect_1.FlexPLMConnect(cfg(userA)).sendToFlexPLM(payload);
313
+ expect(callHeaders(4).Cookie).toBeUndefined();
314
+ nowSpy.mockRestore();
315
+ });
316
+ it('should not hold more than the maximum number of cached sessions', async () => {
317
+ const first = uniqueUser();
318
+ fetchMock().mockImplementation(async () => resp(200, ['JSESSIONID=s.tomcat1']));
319
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(first)).sendToFlexPLM(payload);
320
+ for (let i = 0; i < 100; i++) {
321
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(uniqueUser())).sendToFlexPLM(payload);
322
+ }
323
+ fetchMock().mockClear();
324
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(first)).sendToFlexPLM(payload);
325
+ expect(callHeaders(0).Cookie).toBeUndefined(); // oldest entry was evicted
326
+ });
327
+ it('should keep separate sessions per user', async () => {
300
328
  const a = uniqueUser();
301
329
  const b = uniqueUser();
302
330
  fetchMock()
@@ -309,7 +337,7 @@ describe('FlexPLMConnect session reuse', () => {
309
337
  expect(callHeaders(2).Cookie).toBeUndefined(); // user b must not see user a's cookie
310
338
  expect(callHeaders(3).Cookie).toBe('JSESSIONID=bbb.tomcat1');
311
339
  });
312
- it('never logs the cookie value', async () => {
340
+ it('should never log the cookie value', async () => {
313
341
  const isInfoOn = jest.spyOn(app_framework_1.Logger, 'isInfoOn').mockReturnValue(true);
314
342
  const info = jest.spyOn(console, 'info').mockImplementation();
315
343
  const u = uniqueUser();
@@ -326,10 +354,228 @@ describe('FlexPLMConnect session reuse', () => {
326
354
  isInfoOn.mockRestore();
327
355
  info.mockRestore();
328
356
  });
357
+ describe('option normalisation', () => {
358
+ const rejectedOnceThenOk = (u, opts) => {
359
+ fetchMock()
360
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
361
+ .mockResolvedValueOnce(resp(403, [], {}))
362
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
363
+ .mockResolvedValueOnce(resp(200, [], {}));
364
+ return new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ...opts })).sendToFlexPLM(payload);
365
+ };
366
+ it.each([
367
+ ['a scalar number', 403],
368
+ ['a comma separated string', '401, 403'],
369
+ ['garbage (falls back to 401/403)', 'abc'],
370
+ ['an empty array (falls back to 401/403)', []],
371
+ ])('should not throw and still retry on 403 when retryStatuses is %s', async (_label, retryStatuses) => {
372
+ await rejectedOnceThenOk(uniqueUser(), { retryStatuses });
373
+ expect(fetchMock()).toHaveBeenCalledTimes(4);
374
+ });
375
+ it.each([['abc'], [0], [-5]])('should fall back to the 900s default when ttlSeconds is invalid (%p)', async (ttlSeconds) => {
376
+ const u = uniqueUser();
377
+ const realNow = Date.now();
378
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
379
+ fetchMock()
380
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
381
+ .mockResolvedValueOnce(resp(200, [], {}))
382
+ .mockResolvedValueOnce(resp(200, []))
383
+ .mockResolvedValueOnce(resp(200, [], {}));
384
+ const cfg = sessionConfig(u, { enabled: true, ttlSeconds });
385
+ await new flexplm_connect_1.FlexPLMConnect(cfg).sendToFlexPLM(payload);
386
+ nowSpy.mockReturnValue(realNow + 2000);
387
+ await new flexplm_connect_1.FlexPLMConnect(cfg).sendToFlexPLM(payload);
388
+ expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
389
+ nowSpy.mockRestore();
390
+ });
391
+ it('should fall back to JSESSIONID when cookieName is blank', async () => {
392
+ const u = uniqueUser();
393
+ fetchMock()
394
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
395
+ .mockResolvedValueOnce(resp(200, [], {}));
396
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: ' ' })).sendToFlexPLM(payload);
397
+ expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
398
+ });
399
+ });
400
+ describe('retryStatuses restricted to client-error statuses', () => {
401
+ const postFailsWith = async (status, retryStatuses) => {
402
+ const u = uniqueUser();
403
+ fetchMock()
404
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
405
+ .mockResolvedValueOnce(resp(status, [], {}));
406
+ await expect(new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, retryStatuses })).sendToFlexPLM({ id: '1' }))
407
+ .rejects.toThrow('Error sending data to FlexPLM:status: ' + status);
408
+ expect(fetchMock()).toHaveBeenCalledTimes(2); // no retry
409
+ };
410
+ it.each([[500], ['500'], [[200, 500]]])('should ignore non-4xx retryStatuses (%p) and not retry a 500', async (retryStatuses) => {
411
+ await postFailsWith(500, retryStatuses);
412
+ });
413
+ it('should keep 4xx entries and drop others from a mixed retryStatuses list', async () => {
414
+ await postFailsWith(500, [401, 500]);
415
+ });
416
+ it('should not resend a successful event when retryStatuses is configured as 200', async () => {
417
+ const u = uniqueUser();
418
+ fetchMock()
419
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
420
+ .mockResolvedValueOnce(resp(200, [], {}));
421
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, retryStatuses: 200 })).sendToFlexPLM({ id: '1' });
422
+ expect(fetchMock()).toHaveBeenCalledTimes(2);
423
+ });
424
+ });
425
+ describe('session cache key includes session options', () => {
426
+ it('should not share a cached cookie between different cookieNames for the same user', async () => {
427
+ const u = uniqueUser();
428
+ fetchMock()
429
+ .mockResolvedValueOnce(resp(200, ['AAA=a1; Path=/']))
430
+ .mockResolvedValueOnce(resp(200, [], {}))
431
+ .mockResolvedValueOnce(resp(200, ['BBB=b1; Path=/']))
432
+ .mockResolvedValueOnce(resp(200, [], {}));
433
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'AAA' })).sendToFlexPLM({ id: '1' });
434
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'BBB' })).sendToFlexPLM({ id: '1' });
435
+ expect(callHeaders(2).Cookie).toBeUndefined(); // BBB config must not pick up the AAA session
436
+ expect(callHeaders(3).Cookie).toBe('BBB=b1');
437
+ });
438
+ it('should not share a cached session between different ttlSeconds for the same user', async () => {
439
+ const u = uniqueUser();
440
+ fetchMock()
441
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=short.tomcat1']))
442
+ .mockResolvedValueOnce(resp(200, [], {}))
443
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=long.tomcat1']))
444
+ .mockResolvedValueOnce(resp(200, [], {}));
445
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ttlSeconds: 1 })).sendToFlexPLM({ id: '1' });
446
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ttlSeconds: 900 })).sendToFlexPLM({ id: '1' });
447
+ expect(callHeaders(2).Cookie).toBeUndefined();
448
+ expect(callHeaders(3).Cookie).toBe('JSESSIONID=long.tomcat1');
449
+ });
450
+ });
451
+ describe('rejected response disposal', () => {
452
+ const withBody = (r) => Object.assign(r, { body: { cancel: jest.fn().mockResolvedValue(undefined) } });
453
+ it('should cancel the body of a POST rejected with 403 before retrying', async () => {
454
+ const u = uniqueUser();
455
+ const rejected = withBody(resp(403, [], {}));
456
+ fetchMock()
457
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
458
+ .mockResolvedValueOnce(rejected)
459
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
460
+ .mockResolvedValueOnce(resp(200, [], {}));
461
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
462
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
463
+ });
464
+ it('should cancel the body of a CSRF GET rejected with the cached cookie before retrying', async () => {
465
+ const u = uniqueUser();
466
+ const rejected = withBody(resp(401, []));
467
+ fetchMock()
468
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
469
+ .mockResolvedValueOnce(resp(200, [], {}))
470
+ .mockResolvedValueOnce(rejected)
471
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
472
+ .mockResolvedValueOnce(resp(200, [], {}));
473
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
474
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
475
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
476
+ });
477
+ it('should cancel the body of a getRequest rejected with the cached cookie before retrying', async () => {
478
+ const u = uniqueUser();
479
+ const rejected = withBody(resp(401, []));
480
+ fetchMock()
481
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
482
+ .mockResolvedValueOnce(resp(200, [], {}))
483
+ .mockResolvedValueOnce(rejected)
484
+ .mockResolvedValueOnce(resp(200, [], { items: [] }));
485
+ const connect = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u));
486
+ await connect.sendToFlexPLM({ id: '1' });
487
+ await connect.getRequest();
488
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
489
+ });
490
+ it('should still retry when cancelling the body throws', async () => {
491
+ const u = uniqueUser();
492
+ const rejected = resp(403, [], {});
493
+ rejected.body = { cancel: jest.fn().mockRejectedValue(new Error('locked')) };
494
+ fetchMock()
495
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
496
+ .mockResolvedValueOnce(rejected)
497
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
498
+ .mockResolvedValueOnce(resp(200, [], {}));
499
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
500
+ expect(fetchMock()).toHaveBeenCalledTimes(4);
501
+ });
502
+ });
503
+ describe('cookie names containing regex metacharacters', () => {
504
+ it('should extract a cookie whose name contains "+"', async () => {
505
+ const u = uniqueUser();
506
+ fetchMock()
507
+ .mockResolvedValueOnce(resp(200, ['SID+=abc; Path=/']))
508
+ .mockResolvedValueOnce(resp(200, [], {}));
509
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'SID+' })).sendToFlexPLM(payload);
510
+ expect(callHeaders(1).Cookie).toBe('SID+=abc');
511
+ });
512
+ it('should treat "." literally and not match a look-alike cookie', async () => {
513
+ const u = uniqueUser();
514
+ fetchMock()
515
+ .mockResolvedValueOnce(resp(200, ['AxB=wrong; Path=/']))
516
+ .mockResolvedValueOnce(resp(200, [], {}));
517
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'A.B' })).sendToFlexPLM(payload);
518
+ expect(callHeaders(1).Cookie).toBeUndefined();
519
+ });
520
+ });
521
+ describe('concurrent session acquisition', () => {
522
+ const flush = () => new Promise(r => setImmediate(r));
523
+ const isColdGet = (call) => call[1].method === 'GET' && !call[1].headers.Cookie;
524
+ it('should share one session across concurrent events on a cold cache', async () => {
525
+ const u = uniqueUser();
526
+ let release;
527
+ const gate = new Promise(r => { release = r; });
528
+ fetchMock().mockImplementation(async (_url, opts) => {
529
+ if (opts.method === 'GET' && !opts.headers.Cookie) {
530
+ await gate;
531
+ return resp(200, ['JSESSIONID=abc.tomcat1']);
532
+ }
533
+ return resp(200, []);
534
+ });
535
+ const all = Promise.all([1, 2, 3].map(() => new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF()));
536
+ await flush();
537
+ expect(fetchMock()).toHaveBeenCalledTimes(1); // the other two are waiting for the first acquisition
538
+ release();
539
+ const results = await all;
540
+ expect(fetchMock().mock.calls.filter(isColdGet)).toHaveLength(1);
541
+ results.forEach(r => expect(r.sessionCookie).toBe('abc.tomcat1'));
542
+ });
543
+ it('should not make a waiter inherit the failure of the first acquisition', async () => {
544
+ const u = uniqueUser();
545
+ let release;
546
+ const gate = new Promise(r => { release = r; });
547
+ fetchMock()
548
+ .mockImplementationOnce(async () => { await gate; return resp(500, []); })
549
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']));
550
+ const first = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF();
551
+ const firstResult = first.catch(e => e);
552
+ const second = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF();
553
+ await flush();
554
+ release();
555
+ expect(await firstResult).toBeInstanceOf(Error);
556
+ expect((await second).sessionCookie).toBe('abc.tomcat1');
557
+ });
558
+ it('should create only one replacement session for parallel events rejected with 403', async () => {
559
+ const u = uniqueUser();
560
+ fetchMock().mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']));
561
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF(); // prime the cache with the old session
562
+ fetchMock().mockReset();
563
+ fetchMock().mockImplementation(async (_url, opts) => {
564
+ const cookie = opts.headers.Cookie;
565
+ if (opts.method === 'POST')
566
+ return cookie === 'JSESSIONID=new.tomcat1' ? resp(200, [], {}) : resp(403, [], {});
567
+ if (!cookie)
568
+ return resp(200, ['JSESSIONID=new.tomcat1']);
569
+ return resp(200, []);
570
+ });
571
+ await Promise.all([1, 2, 3].map(() => new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload)));
572
+ expect(fetchMock().mock.calls.filter(isColdGet)).toHaveLength(1);
573
+ });
574
+ });
329
575
  });
330
576
  describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
331
577
  const payload = { id: '123' };
332
- it('sends no Cookie header and makes exactly one GET + one POST', async () => {
578
+ it('should send no Cookie header and make exactly one GET + one POST', async () => {
333
579
  const u = uniqueUser();
334
580
  fetchMock()
335
581
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1'])) // Set-Cookie present but must be ignored
@@ -343,7 +589,7 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
343
589
  expect(callHeaders(i).Cookie).toBeUndefined();
344
590
  }
345
591
  });
346
- it('does not retry on 403 (behaves exactly as before)', async () => {
592
+ it('should not retry on 403 (behaves exactly as before)', async () => {
347
593
  const u = uniqueUser();
348
594
  fetchMock()
349
595
  .mockResolvedValueOnce(resp(200))
@@ -352,7 +598,7 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
352
598
  .rejects.toThrow('Error sending data to FlexPLM:status: 403');
353
599
  expect(fetchMock()).toHaveBeenCalledTimes(2);
354
600
  });
355
- it('explicit enabled:false behaves the same as not configured', async () => {
601
+ it('should behave the same as not configured when enabled is false', async () => {
356
602
  const u = uniqueUser();
357
603
  fetchMock()
358
604
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
@@ -361,8 +607,8 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
361
607
  expect(callHeaders(1).Cookie).toBeUndefined();
362
608
  });
363
609
  });
364
- describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)', () => {
365
- it('sends the cached cookie on image/GET requests', async () => {
610
+ describe('FlexPLMConnect.getRequest session reuse', () => {
611
+ it('should send the cached cookie on image/GET requests', async () => {
366
612
  const u = uniqueUser();
367
613
  fetchMock()
368
614
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1'])) // csrf GET primes the cache
@@ -373,7 +619,7 @@ describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)'
373
619
  await connect.getRequest();
374
620
  expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
375
621
  });
376
- it('retries once without the cookie when the cached session is rejected on getRequest', async () => {
622
+ it('should retry once without the cookie when the cached session is rejected on getRequest', async () => {
377
623
  const u = uniqueUser();
378
624
  fetchMock()
379
625
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
@@ -386,5 +632,20 @@ describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)'
386
632
  expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
387
633
  expect(callHeaders(3).Cookie).toBeUndefined();
388
634
  });
635
+ it('should not re-store the rejected cookie when the retry issues no Set-Cookie', async () => {
636
+ const u = uniqueUser();
637
+ fetchMock()
638
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=stale.tomcat1']))
639
+ .mockResolvedValueOnce(resp(200, [], {}))
640
+ .mockResolvedValueOnce(resp(401, [])) // getRequest with the cached cookie
641
+ .mockResolvedValueOnce(resp(200, [], { items: [] })) // retry succeeds, no Set-Cookie
642
+ .mockResolvedValueOnce(resp(200, [], { items: [] })); // next getRequest
643
+ const connect = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u));
644
+ await connect.sendToFlexPLM({ id: '1' });
645
+ await connect.getRequest();
646
+ await connect.getRequest();
647
+ expect(callHeaders(3).Cookie).toBeUndefined();
648
+ expect(callHeaders(4).Cookie).toBeUndefined(); // stale cookie must not be sent again
649
+ });
389
650
  });
390
651
  // Added for Session reuse - ET-390 - Ends
@@ -0,0 +1,29 @@
1
+ import { FCConfig } from '../interfaces/interfaces';
2
+ export declare class SessionRejectedError extends Error {
3
+ }
4
+ /** Owns the FlexPLM session cookie cache: options, keying, expiry, in-flight acquisition sharing and Set-Cookie parsing. */
5
+ export declare class FlexPLMSessionManager {
6
+ private config;
7
+ constructor(config: FCConfig);
8
+ get options(): {
9
+ enabled: boolean;
10
+ ttlMs: number;
11
+ cookieName: string;
12
+ retryStatuses: number[];
13
+ };
14
+ /**
15
+ * Waits until no cold session acquisition is running for this key.
16
+ * Callers must `await` this only when it returns a promise: awaiting even a resolved promise yields,
17
+ * and the check-then-register in runColdAcquisition has to stay synchronous to be race free.
18
+ */
19
+ waitForInFlightAcquisition(): Promise<void> | undefined;
20
+ /** Runs a cold acquisition and registers it so concurrent callers wait for it instead of opening their own session. */
21
+ runColdAcquisition<T>(acquire: () => Promise<T>): Promise<T>;
22
+ private getSessionKey;
23
+ getCachedSession(): string | undefined;
24
+ storeSession(cookie: string): void;
25
+ clearSession(): void;
26
+ /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
27
+ extractSessionCookie(response: Response): string | undefined;
28
+ cookieHeader(sessionCookie?: string): string | undefined;
29
+ }
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FlexPLMSessionManager = exports.SessionRejectedError = void 0;
4
+ // Module-level on purpose: a new FlexPLMConnect (and session manager) is constructed for every event.
5
+ const sessionCache = new Map();
6
+ // Upper bound so users/option combinations that are never requested again cannot accumulate cookies forever.
7
+ const MAX_CACHED_SESSIONS = 100;
8
+ const pruneSessionCache = () => {
9
+ const now = Date.now();
10
+ for (const [key, session] of sessionCache) {
11
+ if (session.expiresAt <= now)
12
+ sessionCache.delete(key);
13
+ }
14
+ // Still full: drop the oldest entries (Map keeps insertion order).
15
+ while (sessionCache.size >= MAX_CACHED_SESSIONS) {
16
+ sessionCache.delete(sessionCache.keys().next().value);
17
+ }
18
+ };
19
+ // In-flight cold session acquisitions per key, so concurrent events share one FlexPLM session instead of each creating their own.
20
+ const inFlightAcquisitions = new Map();
21
+ const DEFAULT_SESSION_COOKIE_NAME = 'JSESSIONID';
22
+ const DEFAULT_SESSION_TTL_SECONDS = 900;
23
+ const DEFAULT_RETRY_STATUSES = [401, 403];
24
+ const COOKIE_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
25
+ // Thrown internally when a cached cookie was rejected, so the caller can re-acquire under the lock.
26
+ class SessionRejectedError extends Error {
27
+ }
28
+ exports.SessionRejectedError = SessionRejectedError;
29
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+ const parseRetryStatuses = (value) => {
31
+ let list = [];
32
+ if (Array.isArray(value))
33
+ list = value;
34
+ else if (typeof value === 'string')
35
+ list = value.split(',');
36
+ else if (value !== undefined && value !== null)
37
+ list = [value];
38
+ const statuses = list
39
+ .map(status => (typeof status === 'string' ? status.trim() : status))
40
+ .filter(status => status !== '' && status !== null && status !== undefined)
41
+ .map(Number)
42
+ // Only client-error statuses can mean "session rejected". 2xx/5xx would resend events the server already processed.
43
+ .filter(status => Number.isInteger(status) && status >= 400 && status <= 499);
44
+ return statuses.length ? statuses : DEFAULT_RETRY_STATUSES;
45
+ };
46
+ const parseTtlSeconds = (value) => {
47
+ const ttl = Number(value);
48
+ return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SECONDS;
49
+ };
50
+ const parseCookieName = (value) => {
51
+ const name = typeof value === 'string' ? value.trim() : '';
52
+ return COOKIE_NAME_TOKEN.test(name) ? name : DEFAULT_SESSION_COOKIE_NAME;
53
+ };
54
+ /** Owns the FlexPLM session cookie cache: options, keying, expiry, in-flight acquisition sharing and Set-Cookie parsing. */
55
+ class FlexPLMSessionManager {
56
+ constructor(config) {
57
+ this.config = config;
58
+ }
59
+ get options() {
60
+ const sessionReuseConfig = this.config?.flexplmConnect?.sessionReuse ?? {};
61
+ return {
62
+ enabled: sessionReuseConfig.enabled === true || String(sessionReuseConfig.enabled).toLowerCase() === 'true',
63
+ ttlMs: parseTtlSeconds(sessionReuseConfig.ttlSeconds) * 1000,
64
+ cookieName: parseCookieName(sessionReuseConfig.cookieName),
65
+ retryStatuses: parseRetryStatuses(sessionReuseConfig.retryStatuses),
66
+ };
67
+ }
68
+ /**
69
+ * Waits until no cold session acquisition is running for this key.
70
+ * Callers must `await` this only when it returns a promise: awaiting even a resolved promise yields,
71
+ * and the check-then-register in runColdAcquisition has to stay synchronous to be race free.
72
+ */
73
+ waitForInFlightAcquisition() {
74
+ const key = this.getSessionKey();
75
+ if (!inFlightAcquisitions.has(key))
76
+ return undefined;
77
+ return (async () => {
78
+ let pending = inFlightAcquisitions.get(key);
79
+ while (pending) {
80
+ await pending.catch(() => undefined);
81
+ pending = inFlightAcquisitions.get(key);
82
+ }
83
+ })();
84
+ }
85
+ /** Runs a cold acquisition and registers it so concurrent callers wait for it instead of opening their own session. */
86
+ async runColdAcquisition(acquire) {
87
+ const key = this.getSessionKey();
88
+ const acquisition = acquire();
89
+ inFlightAcquisitions.set(key, acquisition);
90
+ try {
91
+ return await acquisition;
92
+ }
93
+ finally {
94
+ if (inFlightAcquisitions.get(key) === acquisition)
95
+ inFlightAcquisitions.delete(key);
96
+ }
97
+ }
98
+ getSessionKey() {
99
+ // Options that change the cached value are part of the key; JSON avoids '|' collisions between fields.
100
+ const { cookieName, ttlMs } = this.options;
101
+ return JSON.stringify([
102
+ this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment, cookieName, ttlMs,
103
+ ]);
104
+ }
105
+ getCachedSession() {
106
+ if (!this.options.enabled)
107
+ return undefined;
108
+ const key = this.getSessionKey();
109
+ const cachedSessValue = sessionCache.get(key);
110
+ if (!cachedSessValue)
111
+ return undefined;
112
+ if (cachedSessValue.expiresAt <= Date.now()) {
113
+ sessionCache.delete(key);
114
+ return undefined;
115
+ }
116
+ return cachedSessValue.cookie;
117
+ }
118
+ storeSession(cookie) {
119
+ // sliding expiry: Windchill's session timeout is also sliding
120
+ const key = this.getSessionKey();
121
+ // delete first so a refreshed entry moves to the newest position (oldest-first eviction then acts as LRU)
122
+ sessionCache.delete(key);
123
+ pruneSessionCache();
124
+ sessionCache.set(key, { cookie, expiresAt: Date.now() + this.options.ttlMs });
125
+ }
126
+ clearSession() {
127
+ sessionCache.delete(this.getSessionKey());
128
+ }
129
+ /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
130
+ extractSessionCookie(response) {
131
+ const respHeader = response?.headers;
132
+ if (!respHeader)
133
+ return undefined;
134
+ let raw = [];
135
+ if (typeof respHeader.getSetCookie === 'function') {
136
+ raw = respHeader.getSetCookie();
137
+ }
138
+ else if (typeof respHeader.get === 'function' && respHeader.get('set-cookie')) {
139
+ raw = [respHeader.get('set-cookie')];
140
+ }
141
+ const name = this.options.cookieName;
142
+ const regexFilter = new RegExp('(?:^|[,\\s])' + escapeRegExp(name) + '=([^;,\\s]+)', 'g');
143
+ let jSession;
144
+ for (const line of raw) {
145
+ let cookieNameMatcher = regexFilter.exec(line);
146
+ while (cookieNameMatcher !== null) {
147
+ jSession = cookieNameMatcher[1];
148
+ cookieNameMatcher = regexFilter.exec(line);
149
+ }
150
+ }
151
+ return jSession;
152
+ }
153
+ cookieHeader(sessionCookie) {
154
+ return sessionCookie ? `${this.options.cookieName}=${sessionCookie}` : undefined;
155
+ }
156
+ }
157
+ exports.FlexPLMSessionManager = FlexPLMSessionManager;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/flexplm",
3
- "version": "1.7.7-alpha.24a6b7d",
3
+ "version": "1.7.7-alpha.279b642",
4
4
  "description": "Library used for integration with flexplm.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",