astro-tokenkit 1.0.17 → 1.0.19

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/README.md CHANGED
@@ -114,6 +114,7 @@ const specializedClient = createClient({
114
114
  | `timeout` | `number` | Request timeout in milliseconds (default: 30000). |
115
115
  | `retry` | `RetryConfig` | Retry strategy for failed requests. |
116
116
  | `interceptors`| `InterceptorsConfig` | Request/Response/Error interceptors. |
117
+ | `idle` | `IdleConfig` | Inactivity session timeout configuration. |
117
118
  | `context` | `AsyncLocalStorage` | External AsyncLocalStorage instance. |
118
119
  | `getContextStore`| `() => TokenKitContext`| Custom method to retrieve the context store. |
119
120
  | `setContextStore`| `(ctx) => void`| Custom method to set the context store. |
@@ -138,6 +139,47 @@ const specializedClient = createClient({
138
139
  | `cookies` | `CookieConfig` | Configuration for auth cookies. |
139
140
  | `policy` | `RefreshPolicy` | Strategy for when to trigger token refresh. |
140
141
 
142
+ ### Idle Session Timeout
143
+
144
+ Astro TokenKit automatically monitors user inactivity and closes the session across all open tabs. This feature uses `BroadcastChannel` to synchronize activity and logout events.
145
+
146
+ **Important:** When using the Astro integration, the `onIdle` function cannot be passed in `astro.config.mjs` because it is not serializable. Instead, listen for the `tk:idle` event on the client.
147
+
148
+ | Property | Type | Description |
149
+ | :--- | :--- | :--- |
150
+ | `timeout` | `number` | **Required.** Inactivity timeout in seconds. |
151
+ | `autoLogout`| `boolean` | Whether to automatically trigger logout by calling the configured logout endpoint (default: `true`). |
152
+ | `activeTabOnly` | `boolean` | Whether to track activity only on the active tab to save CPU/memory (default: `true`). |
153
+ | `alert` | `any` | Custom data to be passed to the `tk:idle` event. Ideal for configuring SweetAlert options. |
154
+
155
+ #### Handling Idle Events (e.g. SweetAlert)
156
+
157
+ On the client (browser), you can listen for the `tk:idle` event to show a notification. You can use the `alert` property from your configuration to pass options to your alert plugin.
158
+
159
+ ```javascript
160
+ // astro.config.mjs
161
+ tokenKit({
162
+ idle: {
163
+ timeout: 300,
164
+ alert: {
165
+ title: "Session Expired",
166
+ text: "You have been logged out due to inactivity.",
167
+ icon: "warning"
168
+ }
169
+ }
170
+ })
171
+ ```
172
+
173
+ ```html
174
+ <script>
175
+ window.addEventListener('tk:idle', (event) => {
176
+ const options = event.detail.alert;
177
+ // Use SweetAlert or any other plugin
178
+ swal(options);
179
+ });
180
+ </script>
181
+ ```
182
+
141
183
  ### Login Options
142
184
 
143
185
  | Property | Type | Description |
@@ -242,6 +284,26 @@ api.login(credentials)
242
284
 
243
285
  > **Note:** Since all methods return an `APIResponse` object, you can use destructuring in `.then()` to access the data directly, which allows for clean syntax like `.then(({ data: token }) => ... )`.
244
286
 
287
+ ## Performance
288
+
289
+ Astro TokenKit is designed with a "low impact" philosophy. It introduces negligible overhead to your requests while providing powerful features like automatic token rotation.
290
+
291
+ ### Benchmark Results
292
+
293
+ Run on a standard development machine using `npm run bench`:
294
+
295
+ | Scenario | Operations/sec | Latency (Overhead) |
296
+ | :--- | :--- | :--- |
297
+ | **Native fetch (Baseline)** | ~720,000 | 0µs |
298
+ | **Middleware overhead** | ~1,680,000 | <1µs |
299
+ | **APIClient (No Auth)** | ~200,000 | ~3.5µs |
300
+ | **APIClient (With Auth)** | ~150,000 | ~5.3µs |
301
+
302
+ **Key Takeaways:**
303
+ - **Zero-impact Middleware:** The middleware adds less than 1 microsecond to each Astro request.
304
+ - **Ultra-low Client Overhead:** Using the `APIClient` adds about 3-5 microseconds per request compared to native `fetch`.
305
+ - **Negligible in Real World:** In a typical scenario where a network request takes 10ms (10,000µs), Astro TokenKit adds less than **0.05%** latency.
306
+
245
307
  ## License
246
308
 
247
309
  MIT © [oamm](https://github.com/oamm)
@@ -23,7 +23,7 @@ export declare class TokenManager {
23
23
  /**
24
24
  * Ensure valid tokens (with automatic refresh)
25
25
  */
26
- ensure(ctx: TokenKitContext, options?: AuthOptions, headers?: Record<string, string>): Promise<Session | null>;
26
+ ensure(ctx: TokenKitContext, options?: AuthOptions, headers?: Record<string, string>, force?: boolean): Promise<Session | null>;
27
27
  /**
28
28
  * Logout (clear tokens)
29
29
  */
@@ -26,21 +26,18 @@ class SingleFlight {
26
26
  const existing = this.inFlight.get(key);
27
27
  if (existing)
28
28
  return existing;
29
- const promise = this.doExecute(key, fn);
29
+ const promise = (() => __awaiter(this, void 0, void 0, function* () {
30
+ try {
31
+ return yield fn();
32
+ }
33
+ finally {
34
+ this.inFlight.delete(key);
35
+ }
36
+ }))();
30
37
  this.inFlight.set(key, promise);
31
38
  return promise;
32
39
  });
33
40
  }
34
- doExecute(key, fn) {
35
- return __awaiter(this, void 0, void 0, function* () {
36
- try {
37
- return yield fn();
38
- }
39
- finally {
40
- this.inFlight.delete(key);
41
- }
42
- });
43
- }
44
41
  }
45
42
  /**
46
43
  * Token Manager handles all token operations
@@ -56,6 +53,7 @@ export class TokenManager {
56
53
  */
57
54
  login(ctx, credentials, options) {
58
55
  return __awaiter(this, void 0, void 0, function* () {
56
+ var _a, _b;
59
57
  const url = this.joinURL(this.baseURL, this.config.login);
60
58
  const contentType = this.config.contentType || 'application/json';
61
59
  const headers = Object.assign(Object.assign({ 'Content-Type': contentType }, this.config.headers), options === null || options === void 0 ? void 0 : options.headers);
@@ -67,12 +65,16 @@ export class TokenManager {
67
65
  else {
68
66
  requestBody = JSON.stringify(data);
69
67
  }
68
+ const timeout = (_b = (_a = options === null || options === void 0 ? void 0 : options.timeout) !== null && _a !== void 0 ? _a : this.config.timeout) !== null && _b !== void 0 ? _b : 30000;
69
+ const controller = new AbortController();
70
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
70
71
  let response;
71
72
  try {
72
73
  response = yield safeFetch(url, {
73
74
  method: 'POST',
74
75
  headers,
75
76
  body: requestBody,
77
+ signal: controller.signal,
76
78
  }, this.config);
77
79
  }
78
80
  catch (error) {
@@ -81,6 +83,9 @@ export class TokenManager {
81
83
  yield options.onError(authError, ctx);
82
84
  throw authError;
83
85
  }
86
+ finally {
87
+ clearTimeout(timeoutId);
88
+ }
84
89
  if (!response.ok) {
85
90
  const authError = new AuthError(`Login failed: ${response.status} ${response.statusText}`, response.status, response);
86
91
  if (options === null || options === void 0 ? void 0 : options.onError)
@@ -136,6 +141,7 @@ export class TokenManager {
136
141
  */
137
142
  performRefresh(ctx, refreshToken, options, extraHeaders) {
138
143
  return __awaiter(this, void 0, void 0, function* () {
144
+ var _a, _b;
139
145
  const url = this.joinURL(this.baseURL, this.config.refresh);
140
146
  const contentType = this.config.contentType || 'application/json';
141
147
  const headers = Object.assign(Object.assign({ 'Content-Type': contentType }, this.config.headers), extraHeaders);
@@ -148,17 +154,24 @@ export class TokenManager {
148
154
  else {
149
155
  requestBody = JSON.stringify(data);
150
156
  }
157
+ const timeout = (_b = (_a = options === null || options === void 0 ? void 0 : options.timeout) !== null && _a !== void 0 ? _a : this.config.timeout) !== null && _b !== void 0 ? _b : 30000;
158
+ const controller = new AbortController();
159
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
151
160
  let response;
152
161
  try {
153
162
  response = yield safeFetch(url, {
154
163
  method: 'POST',
155
164
  headers,
156
165
  body: requestBody,
166
+ signal: controller.signal,
157
167
  }, this.config);
158
168
  }
159
169
  catch (error) {
160
170
  throw new AuthError(`Refresh request failed: ${error.message}`, undefined, undefined, undefined, error);
161
171
  }
172
+ finally {
173
+ clearTimeout(timeoutId);
174
+ }
162
175
  if (!response.ok) {
163
176
  // 401/403 = invalid refresh token
164
177
  if (response.status === 401 || response.status === 403) {
@@ -190,8 +203,8 @@ export class TokenManager {
190
203
  /**
191
204
  * Ensure valid tokens (with automatic refresh)
192
205
  */
193
- ensure(ctx, options, headers) {
194
- return __awaiter(this, void 0, void 0, function* () {
206
+ ensure(ctx_1, options_1, headers_1) {
207
+ return __awaiter(this, arguments, void 0, function* (ctx, options, headers, force = false) {
195
208
  var _a, _b, _c, _d, _e, _f;
196
209
  const now = Math.floor(Date.now() / 1000);
197
210
  const tokens = retrieveTokens(ctx, this.config.cookies);
@@ -199,12 +212,14 @@ export class TokenManager {
199
212
  if (!tokens.accessToken || !tokens.refreshToken || !tokens.expiresAt) {
200
213
  return null;
201
214
  }
202
- // Token expired
203
- if (isExpired(tokens.expiresAt, now, this.config.policy)) {
215
+ // Token expired or force refresh
216
+ if (force || isExpired(tokens.expiresAt, now, this.config.policy)) {
204
217
  const flightKey = this.createFlightKey(tokens.refreshToken);
205
218
  const bundle = yield this.singleFlight.execute(flightKey, () => this.refresh(ctx, tokens.refreshToken, options, headers));
206
219
  if (!bundle)
207
220
  return null;
221
+ // Ensure tokens are stored in the current context (in case of shared flight)
222
+ storeTokens(ctx, bundle, this.config.cookies);
208
223
  return {
209
224
  accessToken: bundle.accessToken,
210
225
  expiresAt: bundle.accessExpiresAt,
@@ -217,6 +232,8 @@ export class TokenManager {
217
232
  const flightKey = this.createFlightKey(tokens.refreshToken);
218
233
  const bundle = yield this.singleFlight.execute(flightKey, () => this.refresh(ctx, tokens.refreshToken, options, headers));
219
234
  if (bundle) {
235
+ // Ensure tokens are stored in the current context (in case of shared flight)
236
+ storeTokens(ctx, bundle, this.config.cookies);
220
237
  return {
221
238
  accessToken: bundle.accessToken,
222
239
  expiresAt: bundle.accessExpiresAt,
@@ -244,23 +261,33 @@ export class TokenManager {
244
261
  */
245
262
  logout(ctx) {
246
263
  return __awaiter(this, void 0, void 0, function* () {
247
- var _a;
264
+ var _a, _b;
248
265
  // Optionally call logout endpoint
249
266
  if (this.config.logout) {
267
+ const timeout = (_a = this.config.timeout) !== null && _a !== void 0 ? _a : 10000;
268
+ const controller = new AbortController();
269
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
250
270
  try {
251
271
  const url = this.joinURL(this.baseURL, this.config.logout);
252
272
  const session = this.getSession(ctx);
253
273
  const headers = {};
254
274
  if (session === null || session === void 0 ? void 0 : session.accessToken) {
255
- const injectFn = (_a = this.config.injectToken) !== null && _a !== void 0 ? _a : ((token, type) => `${type !== null && type !== void 0 ? type : 'Bearer'} ${token}`);
275
+ const injectFn = (_b = this.config.injectToken) !== null && _b !== void 0 ? _b : ((token, type) => `${type !== null && type !== void 0 ? type : 'Bearer'} ${token}`);
256
276
  headers['Authorization'] = injectFn(session.accessToken, session.tokenType);
257
277
  }
258
- yield safeFetch(url, { method: 'POST', headers }, this.config);
278
+ yield safeFetch(url, {
279
+ method: 'POST',
280
+ headers,
281
+ signal: controller.signal,
282
+ }, this.config);
259
283
  }
260
284
  catch (error) {
261
285
  // Ignore logout endpoint errors
262
286
  logger.debug('[TokenKit] Logout endpoint failed:', error);
263
287
  }
288
+ finally {
289
+ clearTimeout(timeoutId);
290
+ }
264
291
  }
265
292
  clearTokens(ctx, this.config.cookies);
266
293
  });
@@ -1,4 +1,4 @@
1
- import type { APIResponse, ClientConfig, RequestConfig, RequestOptions, Session, TokenKitConfig, LoginOptions, TokenBundle } from '../types';
1
+ import type { APIResponse, ClientConfig, LoginOptions, RequestConfig, RequestOptions, Session, TokenBundle, TokenKitConfig } from '../types';
2
2
  import { TokenManager } from '../auth/manager';
3
3
  /**
4
4
  * API Client
@@ -177,8 +177,8 @@ export class APIClient {
177
177
  clearTimeout(timeoutId);
178
178
  // Handle 401 (try refresh and retry once)
179
179
  if (response.status === 401 && this.tokenManager && !config.skipAuth && attempt === 1) {
180
- // Clear and try fresh session
181
- const session = yield this.tokenManager.ensure(ctx, config.auth, config.headers);
180
+ // Clear and try fresh session (force refresh)
181
+ const session = yield this.tokenManager.ensure(ctx, config.auth, config.headers, true);
182
182
  if (session) {
183
183
  // Retry with new token
184
184
  return this.executeRequest(config, ctx, attempt + 1);
@@ -305,7 +305,7 @@ export class APIClient {
305
305
  throw new Error('Auth is not configured for this client');
306
306
  }
307
307
  const context = getContextStore();
308
- return this.tokenManager.login(context, credentials, options);
308
+ return yield this.tokenManager.login(context, credentials, options);
309
309
  });
310
310
  }
311
311
  /**
@@ -0,0 +1,30 @@
1
+ import type { IdleConfig } from '../types';
2
+ /**
3
+ * IdleManager handles user inactivity across multiple tabs.
4
+ * It uses BroadcastChannel to synchronize activity and logout events.
5
+ */
6
+ export declare class IdleManager {
7
+ private channel;
8
+ private timeout;
9
+ private onIdle;
10
+ private activeTabOnly;
11
+ private rafId;
12
+ private lastCheck;
13
+ private isIdle;
14
+ private eventHandler;
15
+ private config;
16
+ private isMonitoring;
17
+ private lastActivity;
18
+ private expiredTimeKey;
19
+ constructor(config: IdleConfig);
20
+ private start;
21
+ private loop;
22
+ private setupEventListeners;
23
+ private addTrackers;
24
+ private removeTrackers;
25
+ private reportActivity;
26
+ private updateExpiredTimeLocal;
27
+ private handleTimeout;
28
+ private triggerIdle;
29
+ cleanup(): void;
30
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * IdleManager handles user inactivity across multiple tabs.
3
+ * It uses BroadcastChannel to synchronize activity and logout events.
4
+ */
5
+ export class IdleManager {
6
+ constructor(config) {
7
+ var _a;
8
+ this.channel = null;
9
+ this.rafId = null;
10
+ this.lastCheck = 0;
11
+ this.isIdle = false;
12
+ this.isMonitoring = false;
13
+ this.lastActivity = 0;
14
+ this.config = config;
15
+ this.timeout = config.timeout;
16
+ this.onIdle = config.onIdle || (() => { });
17
+ this.activeTabOnly = (_a = config.activeTabOnly) !== null && _a !== void 0 ? _a : true;
18
+ this.expiredTimeKey = '_tk_idle_expires';
19
+ this.eventHandler = this.reportActivity.bind(this);
20
+ this.isIdle = false;
21
+ if (typeof window === 'undefined')
22
+ return;
23
+ try {
24
+ this.channel = new BroadcastChannel('tk_idle_channel');
25
+ this.channel.onmessage = (event) => {
26
+ if (event.data === 'activity') {
27
+ this.updateExpiredTimeLocal();
28
+ }
29
+ else if (event.data === 'logout') {
30
+ this.triggerIdle();
31
+ }
32
+ };
33
+ }
34
+ catch (e) {
35
+ // BroadcastChannel might fail in some environments (e.g. private mode)
36
+ }
37
+ this.start();
38
+ }
39
+ start() {
40
+ if (typeof window === 'undefined')
41
+ return;
42
+ this.updateExpiredTimeLocal();
43
+ this.setupEventListeners();
44
+ this.loop();
45
+ }
46
+ loop() {
47
+ if (this.isIdle)
48
+ return;
49
+ const now = Date.now();
50
+ // Check every 1 second
51
+ if (now - this.lastCheck >= 1000) {
52
+ const expiredTime = parseInt(localStorage.getItem(this.expiredTimeKey) || '0', 10);
53
+ if (expiredTime > 0 && now > expiredTime) {
54
+ this.handleTimeout();
55
+ return;
56
+ }
57
+ this.lastCheck = now;
58
+ }
59
+ this.rafId = requestAnimationFrame(() => this.loop());
60
+ }
61
+ setupEventListeners() {
62
+ if (this.activeTabOnly) {
63
+ document.addEventListener('visibilitychange', () => {
64
+ if (document.visibilityState === 'visible') {
65
+ this.addTrackers();
66
+ }
67
+ else {
68
+ this.removeTrackers();
69
+ }
70
+ });
71
+ if (document.visibilityState === 'visible') {
72
+ this.addTrackers();
73
+ }
74
+ }
75
+ else {
76
+ this.addTrackers();
77
+ }
78
+ }
79
+ addTrackers() {
80
+ if (this.isMonitoring)
81
+ return;
82
+ const events = ['mousemove', 'keydown', 'scroll', 'click', 'touchstart'];
83
+ events.forEach(e => window.addEventListener(e, this.eventHandler, { passive: true }));
84
+ this.isMonitoring = true;
85
+ }
86
+ removeTrackers() {
87
+ if (!this.isMonitoring)
88
+ return;
89
+ const events = ['mousemove', 'keydown', 'scroll', 'click', 'touchstart'];
90
+ events.forEach(e => window.removeEventListener(e, this.eventHandler));
91
+ this.isMonitoring = false;
92
+ }
93
+ reportActivity() {
94
+ const now = Date.now();
95
+ // Throttle reporting to every 1 second to reduce overhead
96
+ if (now - this.lastActivity < 1000)
97
+ return;
98
+ this.lastActivity = now;
99
+ this.updateExpiredTimeLocal();
100
+ if (this.channel) {
101
+ this.channel.postMessage('activity');
102
+ }
103
+ }
104
+ updateExpiredTimeLocal() {
105
+ const expires = Date.now() + (this.timeout * 1000);
106
+ localStorage.setItem(this.expiredTimeKey, expires.toString());
107
+ }
108
+ handleTimeout() {
109
+ if (this.isIdle)
110
+ return;
111
+ if (this.channel) {
112
+ this.channel.postMessage('logout');
113
+ }
114
+ this.triggerIdle();
115
+ }
116
+ triggerIdle() {
117
+ if (this.isIdle)
118
+ return;
119
+ this.isIdle = true;
120
+ if (typeof window !== 'undefined') {
121
+ window.dispatchEvent(new CustomEvent('tk:idle', { detail: this.config }));
122
+ }
123
+ this.cleanup();
124
+ this.onIdle();
125
+ }
126
+ cleanup() {
127
+ if (this.rafId) {
128
+ cancelAnimationFrame(this.rafId);
129
+ this.rafId = null;
130
+ }
131
+ this.removeTrackers();
132
+ if (this.channel) {
133
+ this.channel.close();
134
+ this.channel = null;
135
+ }
136
+ localStorage.removeItem(this.expiredTimeKey);
137
+ }
138
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { IdleManager } from './idle-manager';
2
+ if (typeof window !== 'undefined') {
3
+ const config = typeof __TOKENKIT_CONFIG__ !== 'undefined' ? __TOKENKIT_CONFIG__ : {};
4
+ // Initialize Idle Monitoring if configured
5
+ if (config.idle && config.idle.timeout > 0) {
6
+ new IdleManager(Object.assign(Object.assign({}, config.idle), { onIdle: () => {
7
+ var _a;
8
+ // Note: IdleManager dispatches 'tk:idle' automatically
9
+ if (config.idle.autoLogout !== false && ((_a = config.auth) === null || _a === void 0 ? void 0 : _a.logout)) {
10
+ const logoutURL = config.auth.logout.startsWith('http')
11
+ ? config.auth.logout
12
+ : (config.baseURL || '') + config.auth.logout;
13
+ fetch(logoutURL, {
14
+ method: 'POST',
15
+ credentials: 'include'
16
+ }).finally(() => {
17
+ window.location.reload();
18
+ });
19
+ }
20
+ } }));
21
+ }
22
+ }