astro-tokenkit 1.0.16 → 1.0.18

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)
@@ -12,6 +12,8 @@ import { AuthError } from '../types';
12
12
  import { autoDetectFields, parseJWTPayload } from './detector';
13
13
  import { storeTokens, retrieveTokens, clearTokens } from './storage';
14
14
  import { shouldRefresh, isExpired } from './policy';
15
+ import { safeFetch } from '../utils/fetch';
16
+ import { logger } from '../utils/logger';
15
17
  /**
16
18
  * Single-flight refresh manager
17
19
  */
@@ -67,14 +69,14 @@ export class TokenManager {
67
69
  }
68
70
  let response;
69
71
  try {
70
- response = yield fetch(url, {
72
+ response = yield safeFetch(url, {
71
73
  method: 'POST',
72
74
  headers,
73
75
  body: requestBody,
74
- });
76
+ }, this.config);
75
77
  }
76
78
  catch (error) {
77
- const authError = new AuthError(`Login request failed: ${error.message}`);
79
+ const authError = new AuthError(`Login request failed: ${error.message}`, undefined, undefined, undefined, error);
78
80
  if (options === null || options === void 0 ? void 0 : options.onError)
79
81
  yield options.onError(authError, ctx);
80
82
  throw authError;
@@ -148,14 +150,14 @@ export class TokenManager {
148
150
  }
149
151
  let response;
150
152
  try {
151
- response = yield fetch(url, {
153
+ response = yield safeFetch(url, {
152
154
  method: 'POST',
153
155
  headers,
154
156
  body: requestBody,
155
- });
157
+ }, this.config);
156
158
  }
157
159
  catch (error) {
158
- throw new AuthError(`Refresh request failed: ${error.message}`);
160
+ throw new AuthError(`Refresh request failed: ${error.message}`, undefined, undefined, undefined, error);
159
161
  }
160
162
  if (!response.ok) {
161
163
  // 401/403 = invalid refresh token
@@ -253,11 +255,11 @@ export class TokenManager {
253
255
  const injectFn = (_a = this.config.injectToken) !== null && _a !== void 0 ? _a : ((token, type) => `${type !== null && type !== void 0 ? type : 'Bearer'} ${token}`);
254
256
  headers['Authorization'] = injectFn(session.accessToken, session.tokenType);
255
257
  }
256
- yield fetch(url, { method: 'POST', headers });
258
+ yield safeFetch(url, { method: 'POST', headers }, this.config);
257
259
  }
258
260
  catch (error) {
259
261
  // Ignore logout endpoint errors
260
- console.warn('[TokenKit] Logout endpoint failed:', error);
262
+ logger.debug('[TokenKit] Logout endpoint failed:', error);
261
263
  }
262
264
  }
263
265
  clearTokens(ctx, this.config.cookies);
@@ -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
@@ -14,6 +14,7 @@ import { getContextStore } from './context';
14
14
  import { calculateDelay, shouldRetry, sleep } from '../utils/retry';
15
15
  import { getConfig, getTokenManager } from '../config';
16
16
  import { createMiddleware } from '../middleware';
17
+ import { safeFetch } from '../utils/fetch';
17
18
  /**
18
19
  * API Client
19
20
  */
@@ -37,6 +38,7 @@ export class APIClient {
37
38
  * Get token manager
38
39
  */
39
40
  get tokenManager() {
41
+ var _a, _b;
40
42
  const config = this.config;
41
43
  if (!config.auth)
42
44
  return undefined;
@@ -52,7 +54,9 @@ export class APIClient {
52
54
  if (!this._localTokenManager ||
53
55
  this._lastUsedAuth !== config.auth ||
54
56
  this._lastUsedBaseURL !== config.baseURL) {
55
- this._localTokenManager = new TokenManager(config.auth, config.baseURL);
57
+ // Merge client-level fetch and SSL settings into auth config
58
+ const authConfig = Object.assign(Object.assign({}, config.auth), { fetch: (_a = config.auth.fetch) !== null && _a !== void 0 ? _a : config.fetch, dangerouslyIgnoreCertificateErrors: (_b = config.auth.dangerouslyIgnoreCertificateErrors) !== null && _b !== void 0 ? _b : config.dangerouslyIgnoreCertificateErrors });
59
+ this._localTokenManager = new TokenManager(authConfig, config.baseURL);
56
60
  this._lastUsedAuth = config.auth;
57
61
  this._lastUsedBaseURL = config.baseURL;
58
62
  }
@@ -169,7 +173,7 @@ export class APIClient {
169
173
  const controller = new AbortController();
170
174
  const timeoutId = setTimeout(() => controller.abort(), timeout);
171
175
  try {
172
- const response = yield fetch(fullURL, Object.assign(Object.assign({}, init), { signal: controller.signal }));
176
+ const response = yield safeFetch(fullURL, Object.assign(Object.assign({}, init), { signal: controller.signal }), this.config);
173
177
  clearTimeout(timeoutId);
174
178
  // Handle 401 (try refresh and retry once)
175
179
  if (response.status === 401 && this.tokenManager && !config.skipAuth && attempt === 1) {
@@ -202,12 +206,12 @@ export class APIClient {
202
206
  }
203
207
  // Transform errors
204
208
  if (error instanceof Error && error.name === 'AbortError') {
205
- throw new TimeoutError(`Request timeout after ${timeout}ms`, requestConfig);
209
+ throw new TimeoutError(`Request timeout after ${timeout}ms`, requestConfig, error);
206
210
  }
207
211
  if (error instanceof APIError) {
208
212
  throw error;
209
213
  }
210
- throw new NetworkError(error.message, requestConfig);
214
+ throw new NetworkError(error.message, requestConfig, error);
211
215
  }
212
216
  });
213
217
  }
@@ -301,7 +305,7 @@ export class APIClient {
301
305
  throw new Error('Auth is not configured for this client');
302
306
  }
303
307
  const context = getContextStore();
304
- return this.tokenManager.login(context, credentials, options);
308
+ return yield this.tokenManager.login(context, credentials, options);
305
309
  });
306
310
  }
307
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
+ }
package/dist/config.js CHANGED
@@ -10,12 +10,14 @@ if (!globalStorage[CONFIG_KEY]) {
10
10
  getContextStore: undefined,
11
11
  setContextStore: undefined,
12
12
  baseURL: "",
13
+ debug: false,
13
14
  };
14
15
  }
15
16
  /**
16
17
  * Set configuration
17
18
  */
18
19
  export function setConfig(userConfig) {
20
+ var _a, _b;
19
21
  const currentConfig = globalStorage[CONFIG_KEY];
20
22
  const finalConfig = Object.assign(Object.assign({}, currentConfig), userConfig);
21
23
  // Validate that getter and setter are defined together
@@ -26,7 +28,8 @@ export function setConfig(userConfig) {
26
28
  globalStorage[CONFIG_KEY] = finalConfig;
27
29
  // Re-initialize global token manager if auth changed
28
30
  if (finalConfig.auth) {
29
- globalStorage[MANAGER_KEY] = new TokenManager(finalConfig.auth, finalConfig.baseURL);
31
+ const authConfig = Object.assign(Object.assign({}, finalConfig.auth), { fetch: (_a = finalConfig.auth.fetch) !== null && _a !== void 0 ? _a : finalConfig.fetch, dangerouslyIgnoreCertificateErrors: (_b = finalConfig.auth.dangerouslyIgnoreCertificateErrors) !== null && _b !== void 0 ? _b : finalConfig.dangerouslyIgnoreCertificateErrors });
32
+ globalStorage[MANAGER_KEY] = new TokenManager(authConfig, finalConfig.baseURL);
30
33
  }
31
34
  else {
32
35
  globalStorage[MANAGER_KEY] = undefined;