@auth0/auth0-spa-js 2.14.0 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/auth0-spa-js.development.js +694 -665
- package/dist/auth0-spa-js.development.js.map +1 -1
- package/dist/auth0-spa-js.production.esm.js +1 -1
- package/dist/auth0-spa-js.production.esm.js.map +1 -1
- package/dist/auth0-spa-js.production.js +1 -1
- package/dist/auth0-spa-js.production.js.map +1 -1
- package/dist/lib/auth0-spa-js.cjs.js +879 -846
- package/dist/lib/auth0-spa-js.cjs.js.map +1 -1
- package/dist/typings/Auth0Client.d.ts +1 -8
- package/dist/typings/cache/cache-manager.d.ts +9 -5
- package/dist/typings/lock.d.ts +32 -0
- package/dist/typings/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/Auth0Client.ts +111 -146
- package/src/cache/cache-manager.ts +11 -11
- package/src/lock.ts +141 -0
- package/src/version.ts +1 -1
|
@@ -9,6 +9,7 @@ import { MfaApiClient } from './mfa';
|
|
|
9
9
|
export declare class Auth0Client {
|
|
10
10
|
private readonly transactionManager;
|
|
11
11
|
private readonly cacheManager;
|
|
12
|
+
private readonly lockManager;
|
|
12
13
|
private readonly domainUrl;
|
|
13
14
|
private readonly tokenIssuer;
|
|
14
15
|
private readonly scope;
|
|
@@ -33,7 +34,6 @@ export declare class Auth0Client {
|
|
|
33
34
|
*/
|
|
34
35
|
readonly mfa: MfaApiClient;
|
|
35
36
|
private worker?;
|
|
36
|
-
private readonly activeLockKeys;
|
|
37
37
|
private readonly authJsClient;
|
|
38
38
|
private readonly defaultOptions;
|
|
39
39
|
constructor(options: Auth0ClientOptions);
|
|
@@ -236,13 +236,6 @@ export declare class Auth0Client {
|
|
|
236
236
|
private _saveEntryInCache;
|
|
237
237
|
private _getIdTokenFromCache;
|
|
238
238
|
private _getEntryFromCache;
|
|
239
|
-
/**
|
|
240
|
-
* Releases any locks acquired by the current page that are not released yet
|
|
241
|
-
*
|
|
242
|
-
* Get's called on the `pagehide` event.
|
|
243
|
-
* https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
|
|
244
|
-
*/
|
|
245
|
-
private _releaseLockOnPageHide;
|
|
246
239
|
private _requestToken;
|
|
247
240
|
/**
|
|
248
241
|
* ```js
|
|
@@ -43,10 +43,14 @@ export declare class CacheManager {
|
|
|
43
43
|
*/
|
|
44
44
|
private getEntryWithRefreshToken;
|
|
45
45
|
/**
|
|
46
|
-
* Updates in
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
46
|
+
* Updates the refresh token in all cache entries that contain the old refresh token.
|
|
47
|
+
*
|
|
48
|
+
* When a refresh token is rotated, multiple cache entries (for different audiences/scopes)
|
|
49
|
+
* may share the same refresh token. This method propagates the new refresh token to all
|
|
50
|
+
* matching entries.
|
|
51
|
+
*
|
|
52
|
+
* @param oldRefreshToken The refresh token that was used and is now invalid
|
|
53
|
+
* @param newRefreshToken The new refresh token received from the server
|
|
54
|
+
*/
|
|
51
55
|
updateEntry(oldRefreshToken: string, newRefreshToken: string): Promise<void>;
|
|
52
56
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock manager abstraction for cross-tab synchronization.
|
|
3
|
+
* Supports both modern Web Locks API and legacy localStorage-based locking.
|
|
4
|
+
*/
|
|
5
|
+
/** Lock manager interface - callback pattern ensures automatic lock release */
|
|
6
|
+
export interface ILockManager {
|
|
7
|
+
/**
|
|
8
|
+
* Run callback while holding a lock.
|
|
9
|
+
* Lock is automatically released when callback completes or throws.
|
|
10
|
+
*
|
|
11
|
+
* @param key - Lock identifier
|
|
12
|
+
* @param timeout - Maximum time to wait for lock acquisition (ms)
|
|
13
|
+
* @param callback - Function to execute while holding the lock
|
|
14
|
+
* @returns Promise resolving to callback's return value
|
|
15
|
+
* @throws Error if lock cannot be acquired within timeout
|
|
16
|
+
*/
|
|
17
|
+
runWithLock<T>(key: string, timeout: number, callback: () => Promise<T>): Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
/** Web Locks API implementation - true mutex with OS-level queuing */
|
|
20
|
+
export declare class WebLocksApiManager implements ILockManager {
|
|
21
|
+
runWithLock<T>(key: string, timeout: number, callback: () => Promise<T>): Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
/** Legacy localStorage-based locking with retry logic for older browsers */
|
|
24
|
+
export declare class LegacyLockManager implements ILockManager {
|
|
25
|
+
private lock;
|
|
26
|
+
private activeLocks;
|
|
27
|
+
private pagehideHandler;
|
|
28
|
+
constructor();
|
|
29
|
+
runWithLock<T>(key: string, timeout: number, callback: () => Promise<T>): Promise<T>;
|
|
30
|
+
}
|
|
31
|
+
export declare function getLockManager(): ILockManager;
|
|
32
|
+
export declare function resetLockManager(): void;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "2.
|
|
1
|
+
declare const _default: "2.15.0";
|
|
2
2
|
export default _default;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "@auth0/auth0-spa-js",
|
|
4
4
|
"description": "Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.15.0",
|
|
7
7
|
"main": "dist/lib/auth0-spa-js.cjs.js",
|
|
8
8
|
"types": "dist/typings/index.d.ts",
|
|
9
9
|
"module": "dist/auth0-spa-js.production.esm.js",
|
package/src/Auth0Client.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import Lock from 'browser-tabs-lock';
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
createQueryParams,
|
|
5
3
|
runPopup,
|
|
@@ -17,6 +15,8 @@ import {
|
|
|
17
15
|
stripAuth0Client
|
|
18
16
|
} from './utils';
|
|
19
17
|
|
|
18
|
+
import { getLockManager, type ILockManager } from './lock';
|
|
19
|
+
|
|
20
20
|
import { oauthToken } from './api';
|
|
21
21
|
|
|
22
22
|
import { injectDefaultScopes, scopesToRequest } from './scope';
|
|
@@ -131,17 +131,13 @@ type GetTokenSilentlyResult = TokenEndpointResponse & {
|
|
|
131
131
|
audience: string;
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
-
/**
|
|
135
|
-
* @ignore
|
|
136
|
-
*/
|
|
137
|
-
const lock = new Lock();
|
|
138
|
-
|
|
139
134
|
/**
|
|
140
135
|
* Auth0 SDK for Single Page Applications using [Authorization Code Grant Flow with PKCE](https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce).
|
|
141
136
|
*/
|
|
142
137
|
export class Auth0Client {
|
|
143
138
|
private readonly transactionManager: TransactionManager;
|
|
144
139
|
private readonly cacheManager: CacheManager;
|
|
140
|
+
private readonly lockManager: ILockManager;
|
|
145
141
|
private readonly domainUrl: string;
|
|
146
142
|
private readonly tokenIssuer: string;
|
|
147
143
|
private readonly scope: Record<string, string>;
|
|
@@ -170,7 +166,6 @@ export class Auth0Client {
|
|
|
170
166
|
public readonly mfa: MfaApiClient;
|
|
171
167
|
|
|
172
168
|
private worker?: Worker;
|
|
173
|
-
private readonly activeLockKeys: Set<string> = new Set();
|
|
174
169
|
private readonly authJsClient: Auth0AuthJsClient;
|
|
175
170
|
|
|
176
171
|
private readonly defaultOptions: Partial<Auth0ClientOptions> = {
|
|
@@ -193,6 +188,8 @@ export class Auth0Client {
|
|
|
193
188
|
|
|
194
189
|
typeof window !== 'undefined' && validateCrypto();
|
|
195
190
|
|
|
191
|
+
this.lockManager = getLockManager();
|
|
192
|
+
|
|
196
193
|
if (options.cache && options.cacheLocation) {
|
|
197
194
|
console.warn(
|
|
198
195
|
'Both `cache` and `cacheLocation` options have been specified in the Auth0Client configuration; ignoring `cacheLocation` and using `cache`.'
|
|
@@ -886,58 +883,37 @@ export class Auth0Client {
|
|
|
886
883
|
getTokenOptions.authorizationParams.audience || 'default'
|
|
887
884
|
);
|
|
888
885
|
|
|
889
|
-
|
|
890
|
-
|
|
886
|
+
return await this.lockManager.runWithLock(lockKey, 5000, async () => {
|
|
887
|
+
// Check the cache a second time, because it may have been populated
|
|
888
|
+
// by a previous call while this call was waiting to acquire the lock.
|
|
889
|
+
if (cacheMode !== 'off') {
|
|
890
|
+
const entry = await this._getEntryFromCache({
|
|
891
|
+
scope: getTokenOptions.authorizationParams.scope,
|
|
892
|
+
audience:
|
|
893
|
+
getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
|
|
894
|
+
clientId: this.options.clientId
|
|
895
|
+
});
|
|
891
896
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
window.addEventListener('pagehide', this._releaseLockOnPageHide);
|
|
895
|
-
}
|
|
896
|
-
try {
|
|
897
|
-
// Check the cache a second time, because it may have been populated
|
|
898
|
-
// by a previous call while this call was waiting to acquire the lock.
|
|
899
|
-
if (cacheMode !== 'off') {
|
|
900
|
-
const entry = await this._getEntryFromCache({
|
|
901
|
-
scope: getTokenOptions.authorizationParams.scope,
|
|
902
|
-
audience: getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
|
|
903
|
-
clientId: this.options.clientId
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
if (entry) {
|
|
907
|
-
return entry;
|
|
908
|
-
}
|
|
897
|
+
if (entry) {
|
|
898
|
+
return entry;
|
|
909
899
|
}
|
|
900
|
+
}
|
|
910
901
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
902
|
+
const authResult = this.options.useRefreshTokens
|
|
903
|
+
? await this._getTokenUsingRefreshToken(getTokenOptions)
|
|
904
|
+
: await this._getTokenFromIFrame(getTokenOptions);
|
|
914
905
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
token_type,
|
|
918
|
-
access_token,
|
|
919
|
-
oauthTokenScope,
|
|
920
|
-
expires_in
|
|
921
|
-
} = authResult;
|
|
906
|
+
const { id_token, token_type, access_token, oauthTokenScope, expires_in } =
|
|
907
|
+
authResult;
|
|
922
908
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
await lock.releaseLock(lockKey);
|
|
932
|
-
this.activeLockKeys.delete(lockKey);
|
|
933
|
-
// If we have no more locks, we can remove the event listener to clean up
|
|
934
|
-
if (this.activeLockKeys.size === 0) {
|
|
935
|
-
window.removeEventListener('pagehide', this._releaseLockOnPageHide);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
} else {
|
|
939
|
-
throw new TimeoutError();
|
|
940
|
-
}
|
|
909
|
+
return {
|
|
910
|
+
id_token,
|
|
911
|
+
token_type,
|
|
912
|
+
access_token,
|
|
913
|
+
...(oauthTokenScope ? { scope: oauthTokenScope } : null),
|
|
914
|
+
expires_in
|
|
915
|
+
};
|
|
916
|
+
});
|
|
941
917
|
}
|
|
942
918
|
|
|
943
919
|
/**
|
|
@@ -1088,93 +1064,99 @@ export class Auth0Client {
|
|
|
1088
1064
|
// relies on the same transaction context as a top-level `loginWithRedirect`.
|
|
1089
1065
|
// To resolve that, we add a second-level locking that locks only the iframe calls in
|
|
1090
1066
|
// the same way as was done before https://github.com/auth0/auth0-spa-js/pull/1408.
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1067
|
+
try {
|
|
1068
|
+
return await this.lockManager.runWithLock(
|
|
1069
|
+
iframeLockKey,
|
|
1070
|
+
5000,
|
|
1071
|
+
async () => {
|
|
1072
|
+
const params: AuthorizationParams & { scope: string } = {
|
|
1073
|
+
...options.authorizationParams,
|
|
1074
|
+
prompt: 'none'
|
|
1075
|
+
};
|
|
1099
1076
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1077
|
+
const orgHint = this.cookieStorage.get<string>(
|
|
1078
|
+
this.orgHintCookieName
|
|
1079
|
+
);
|
|
1103
1080
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
nonce: nonceIn,
|
|
1108
|
-
code_verifier,
|
|
1109
|
-
redirect_uri,
|
|
1110
|
-
scope,
|
|
1111
|
-
audience
|
|
1112
|
-
} = await this._prepareAuthorizeUrl(
|
|
1113
|
-
params,
|
|
1114
|
-
{ response_mode: 'web_message' },
|
|
1115
|
-
window.location.origin
|
|
1116
|
-
);
|
|
1081
|
+
if (orgHint && !params.organization) {
|
|
1082
|
+
params.organization = orgHint;
|
|
1083
|
+
}
|
|
1117
1084
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1085
|
+
const {
|
|
1086
|
+
url,
|
|
1087
|
+
state: stateIn,
|
|
1088
|
+
nonce: nonceIn,
|
|
1089
|
+
code_verifier,
|
|
1090
|
+
redirect_uri,
|
|
1091
|
+
scope,
|
|
1092
|
+
audience
|
|
1093
|
+
} = await this._prepareAuthorizeUrl(
|
|
1094
|
+
params,
|
|
1095
|
+
{ response_mode: 'web_message' },
|
|
1096
|
+
window.location.origin
|
|
1125
1097
|
);
|
|
1126
|
-
}
|
|
1127
1098
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1099
|
+
// When a browser is running in a Cross-Origin Isolated context, using iframes is not possible.
|
|
1100
|
+
// It doesn't throw an error but times out instead, so we should exit early and inform the user about the reason.
|
|
1101
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated
|
|
1102
|
+
if ((window as any).crossOriginIsolated) {
|
|
1103
|
+
throw new GenericError(
|
|
1104
|
+
'login_required',
|
|
1105
|
+
'The application is running in a Cross-Origin Isolated context, silently retrieving a token without refresh token is not possible.'
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1130
1108
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
try {
|
|
1134
|
-
eventOrigin = new URL(this.domainUrl).origin;
|
|
1135
|
-
} catch {
|
|
1136
|
-
eventOrigin = this.domainUrl;
|
|
1137
|
-
}
|
|
1109
|
+
const authorizeTimeout =
|
|
1110
|
+
options.timeoutInSeconds || this.options.authorizeTimeoutInSeconds;
|
|
1138
1111
|
|
|
1139
|
-
|
|
1112
|
+
// Extract origin from domainUrl, fallback to domainUrl if URL parsing fails
|
|
1113
|
+
let eventOrigin: string;
|
|
1114
|
+
try {
|
|
1115
|
+
eventOrigin = new URL(this.domainUrl).origin;
|
|
1116
|
+
} catch {
|
|
1117
|
+
eventOrigin = this.domainUrl;
|
|
1118
|
+
}
|
|
1140
1119
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1120
|
+
const codeResult = await runIframe(
|
|
1121
|
+
url,
|
|
1122
|
+
eventOrigin,
|
|
1123
|
+
authorizeTimeout
|
|
1124
|
+
);
|
|
1144
1125
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
...options.authorizationParams,
|
|
1148
|
-
code_verifier,
|
|
1149
|
-
code: codeResult.code as string,
|
|
1150
|
-
grant_type: 'authorization_code',
|
|
1151
|
-
redirect_uri,
|
|
1152
|
-
timeout: options.authorizationParams.timeout || this.httpTimeoutMs
|
|
1153
|
-
},
|
|
1154
|
-
{
|
|
1155
|
-
nonceIn,
|
|
1156
|
-
organization: params.organization
|
|
1126
|
+
if (stateIn !== codeResult.state) {
|
|
1127
|
+
throw new GenericError('state_mismatch', 'Invalid state');
|
|
1157
1128
|
}
|
|
1158
|
-
);
|
|
1159
1129
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1130
|
+
const tokenResult = await this._requestToken(
|
|
1131
|
+
{
|
|
1132
|
+
...options.authorizationParams,
|
|
1133
|
+
code_verifier,
|
|
1134
|
+
code: codeResult.code as string,
|
|
1135
|
+
grant_type: 'authorization_code',
|
|
1136
|
+
redirect_uri,
|
|
1137
|
+
timeout: options.authorizationParams.timeout || this.httpTimeoutMs
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
nonceIn,
|
|
1141
|
+
organization: params.organization
|
|
1142
|
+
}
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
return {
|
|
1146
|
+
...tokenResult,
|
|
1147
|
+
scope: scope,
|
|
1148
|
+
oauthTokenScope: tokenResult.scope,
|
|
1149
|
+
audience: audience
|
|
1150
|
+
};
|
|
1171
1151
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1152
|
+
);
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
if (e.error === 'login_required') {
|
|
1155
|
+
this.logout({
|
|
1156
|
+
openUrl: false
|
|
1157
|
+
});
|
|
1175
1158
|
}
|
|
1176
|
-
|
|
1177
|
-
throw new TimeoutError();
|
|
1159
|
+
throw e;
|
|
1178
1160
|
}
|
|
1179
1161
|
}
|
|
1180
1162
|
|
|
@@ -1413,23 +1395,6 @@ export class Auth0Client {
|
|
|
1413
1395
|
}
|
|
1414
1396
|
}
|
|
1415
1397
|
|
|
1416
|
-
/**
|
|
1417
|
-
* Releases any locks acquired by the current page that are not released yet
|
|
1418
|
-
*
|
|
1419
|
-
* Get's called on the `pagehide` event.
|
|
1420
|
-
* https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
|
|
1421
|
-
*/
|
|
1422
|
-
private _releaseLockOnPageHide = async () => {
|
|
1423
|
-
// Release all active locks
|
|
1424
|
-
const lockKeysToRelease = Array.from(this.activeLockKeys);
|
|
1425
|
-
for (const lockKey of lockKeysToRelease) {
|
|
1426
|
-
await lock.releaseLock(lockKey);
|
|
1427
|
-
}
|
|
1428
|
-
this.activeLockKeys.clear();
|
|
1429
|
-
|
|
1430
|
-
window.removeEventListener('pagehide', this._releaseLockOnPageHide);
|
|
1431
|
-
};
|
|
1432
|
-
|
|
1433
1398
|
private async _requestToken(
|
|
1434
1399
|
options:
|
|
1435
1400
|
| PKCERequestTokenOptions
|
|
@@ -270,11 +270,15 @@ export class CacheManager {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
/**
|
|
273
|
-
* Updates in
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
|
|
273
|
+
* Updates the refresh token in all cache entries that contain the old refresh token.
|
|
274
|
+
*
|
|
275
|
+
* When a refresh token is rotated, multiple cache entries (for different audiences/scopes)
|
|
276
|
+
* may share the same refresh token. This method propagates the new refresh token to all
|
|
277
|
+
* matching entries.
|
|
278
|
+
*
|
|
279
|
+
* @param oldRefreshToken The refresh token that was used and is now invalid
|
|
280
|
+
* @param newRefreshToken The new refresh token received from the server
|
|
281
|
+
*/
|
|
278
282
|
async updateEntry(
|
|
279
283
|
oldRefreshToken: string,
|
|
280
284
|
newRefreshToken: string,
|
|
@@ -287,12 +291,8 @@ export class CacheManager {
|
|
|
287
291
|
const entry = await this.cache.get<WrappedCacheEntry>(key);
|
|
288
292
|
|
|
289
293
|
if (entry?.body?.refresh_token === oldRefreshToken) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
refresh_token: newRefreshToken,
|
|
293
|
-
} as CacheEntry;
|
|
294
|
-
|
|
295
|
-
await this.set(cacheEntry);
|
|
294
|
+
entry.body.refresh_token = newRefreshToken;
|
|
295
|
+
await this.cache.set(key, entry);
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
}
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import BrowserTabsLock from 'browser-tabs-lock';
|
|
2
|
+
import { TimeoutError } from './errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lock manager abstraction for cross-tab synchronization.
|
|
6
|
+
* Supports both modern Web Locks API and legacy localStorage-based locking.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Lock manager interface - callback pattern ensures automatic lock release */
|
|
10
|
+
export interface ILockManager {
|
|
11
|
+
/**
|
|
12
|
+
* Run callback while holding a lock.
|
|
13
|
+
* Lock is automatically released when callback completes or throws.
|
|
14
|
+
*
|
|
15
|
+
* @param key - Lock identifier
|
|
16
|
+
* @param timeout - Maximum time to wait for lock acquisition (ms)
|
|
17
|
+
* @param callback - Function to execute while holding the lock
|
|
18
|
+
* @returns Promise resolving to callback's return value
|
|
19
|
+
* @throws Error if lock cannot be acquired within timeout
|
|
20
|
+
*/
|
|
21
|
+
runWithLock<T>(
|
|
22
|
+
key: string,
|
|
23
|
+
timeout: number,
|
|
24
|
+
callback: () => Promise<T>
|
|
25
|
+
): Promise<T>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Web Locks API implementation - true mutex with OS-level queuing */
|
|
29
|
+
export class WebLocksApiManager implements ILockManager {
|
|
30
|
+
async runWithLock<T>(
|
|
31
|
+
key: string,
|
|
32
|
+
timeout: number,
|
|
33
|
+
callback: () => Promise<T>
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return await navigator.locks.request(
|
|
40
|
+
key,
|
|
41
|
+
{ mode: 'exclusive', signal: controller.signal },
|
|
42
|
+
async lock => {
|
|
43
|
+
clearTimeout(timeoutId);
|
|
44
|
+
if (!lock) throw new Error('Lock not available');
|
|
45
|
+
return await callback();
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
} catch (error: any) {
|
|
49
|
+
clearTimeout(timeoutId);
|
|
50
|
+
if (error?.name === 'AbortError') throw new TimeoutError();
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Legacy localStorage-based locking with retry logic for older browsers */
|
|
57
|
+
export class LegacyLockManager implements ILockManager {
|
|
58
|
+
private lock: BrowserTabsLock;
|
|
59
|
+
private activeLocks: Set<string> = new Set();
|
|
60
|
+
private pagehideHandler: () => void;
|
|
61
|
+
|
|
62
|
+
constructor() {
|
|
63
|
+
this.lock = new BrowserTabsLock();
|
|
64
|
+
|
|
65
|
+
this.pagehideHandler = () => {
|
|
66
|
+
this.activeLocks.forEach(key => this.lock.releaseLock(key));
|
|
67
|
+
this.activeLocks.clear();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async runWithLock<T>(
|
|
72
|
+
key: string,
|
|
73
|
+
timeout: number,
|
|
74
|
+
callback: () => Promise<T>
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
// Retry logic to handle race conditions in localStorage-based locking
|
|
77
|
+
const retryAttempts = 10;
|
|
78
|
+
let acquired = false;
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < retryAttempts && !acquired; i++) {
|
|
81
|
+
acquired = await this.lock.acquireLock(key, timeout);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!acquired) {
|
|
85
|
+
throw new TimeoutError();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.activeLocks.add(key);
|
|
89
|
+
|
|
90
|
+
// Add pagehide listener when acquiring first lock
|
|
91
|
+
if (this.activeLocks.size === 1 && typeof window !== 'undefined') {
|
|
92
|
+
window.addEventListener('pagehide', this.pagehideHandler);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
return await callback();
|
|
97
|
+
} finally {
|
|
98
|
+
this.activeLocks.delete(key);
|
|
99
|
+
await this.lock.releaseLock(key);
|
|
100
|
+
|
|
101
|
+
// Remove pagehide listener when all locks are released
|
|
102
|
+
if (this.activeLocks.size === 0 && typeof window !== 'undefined') {
|
|
103
|
+
window.removeEventListener('pagehide', this.pagehideHandler);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Feature detection for Web Locks API support
|
|
111
|
+
*/
|
|
112
|
+
function isWebLocksSupported(): boolean {
|
|
113
|
+
return (
|
|
114
|
+
typeof navigator !== 'undefined' &&
|
|
115
|
+
typeof navigator.locks?.request === 'function'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createLockManager(): ILockManager {
|
|
120
|
+
return isWebLocksSupported()
|
|
121
|
+
? new WebLocksApiManager()
|
|
122
|
+
: new LegacyLockManager();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get the singleton lock manager instance.
|
|
127
|
+
* Uses Web Locks API in modern browsers, falls back to localStorage in older browsers.
|
|
128
|
+
*/
|
|
129
|
+
let lockManager: ILockManager | null = null;
|
|
130
|
+
|
|
131
|
+
export function getLockManager(): ILockManager {
|
|
132
|
+
if (!lockManager) {
|
|
133
|
+
lockManager = createLockManager();
|
|
134
|
+
}
|
|
135
|
+
return lockManager;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// For testing: allow resetting the singleton
|
|
139
|
+
export function resetLockManager(): void {
|
|
140
|
+
lockManager = null;
|
|
141
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default '2.
|
|
1
|
+
export default '2.15.0';
|