@chaosity/location-client-react 0.1.6 → 0.1.7
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,10 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import { GeoPlacesClient, ClientConfig } from '@chaosity/location-client';
|
|
3
3
|
interface LocationClientContextValue {
|
|
4
|
-
config: ClientConfig | null;
|
|
5
4
|
client: GeoPlacesClient | null;
|
|
6
5
|
getToken: () => string | undefined;
|
|
7
|
-
ensureValidToken: () => Promise<void>;
|
|
8
6
|
loading: boolean;
|
|
9
7
|
error: string | null;
|
|
10
8
|
}
|
|
@@ -13,6 +11,7 @@ export interface LocationClientProviderProps {
|
|
|
13
11
|
getConfig: () => Promise<ClientConfig & {
|
|
14
12
|
expiresAt?: number;
|
|
15
13
|
}>;
|
|
14
|
+
/** Seconds before expiry to proactively refresh (default: 60) */
|
|
16
15
|
refreshBuffer?: number;
|
|
17
16
|
}
|
|
18
17
|
export declare function LocationClientProvider({ children, getConfig, refreshBuffer }: LocationClientProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -3,95 +3,92 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { createContext, useContext, useEffect, useState, useRef, useCallback } from 'react';
|
|
4
4
|
import { GeoPlacesClient } from '@chaosity/location-client';
|
|
5
5
|
import debug from 'debug';
|
|
6
|
-
import { jwtDecode } from 'jwt-decode';
|
|
7
6
|
const log = debug('location-client-react:provider');
|
|
8
7
|
const LocationClientContext = createContext(undefined);
|
|
9
8
|
export function LocationClientProvider({ children, getConfig, refreshBuffer = 60 }) {
|
|
10
|
-
const [config, setConfig] = useState(null);
|
|
11
9
|
const [client, setClient] = useState(null);
|
|
12
10
|
const [loading, setLoading] = useState(true);
|
|
13
11
|
const [error, setError] = useState(null);
|
|
12
|
+
// Refs hold live values without triggering re-renders
|
|
14
13
|
const tokenRef = useRef(undefined);
|
|
15
14
|
const expiresAtRef = useRef(null);
|
|
16
15
|
const getConfigRef = useRef(getConfig);
|
|
17
|
-
const
|
|
18
|
-
|
|
16
|
+
const refreshPromiseRef = useRef(null);
|
|
17
|
+
const ensureValidTokenRef = useRef(async () => { });
|
|
18
|
+
// Keep getConfig ref current without recreating callbacks
|
|
19
19
|
useEffect(() => {
|
|
20
20
|
getConfigRef.current = getConfig;
|
|
21
21
|
}, [getConfig]);
|
|
22
|
-
//
|
|
22
|
+
// Stable token getter — passed into GeoPlacesClient so it always reads the live ref
|
|
23
23
|
const getToken = useCallback(() => tokenRef.current, []);
|
|
24
|
-
//
|
|
24
|
+
// Returns true when the token is expired or within the refresh buffer window.
|
|
25
|
+
// Treats unknown expiry as expired so we always refresh on first use.
|
|
25
26
|
const isTokenExpired = useCallback(() => {
|
|
26
27
|
if (!expiresAtRef.current)
|
|
27
|
-
return
|
|
28
|
+
return true;
|
|
28
29
|
return Date.now() >= (expiresAtRef.current - refreshBuffer * 1000);
|
|
29
30
|
}, [refreshBuffer]);
|
|
30
|
-
//
|
|
31
|
+
// Refreshes the token exactly once even when called concurrently.
|
|
32
|
+
// All concurrent callers await the same in-flight promise — same pattern as
|
|
33
|
+
// server-side TokenProvider.tokenPromise deduplication.
|
|
31
34
|
const ensureValidToken = useCallback(async () => {
|
|
32
35
|
if (!isTokenExpired())
|
|
33
36
|
return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
// Deduplicate: if a refresh is already in flight, wait for it instead of firing another
|
|
38
|
+
if (refreshPromiseRef.current) {
|
|
39
|
+
log('Token refresh already in progress, waiting...');
|
|
40
|
+
return refreshPromiseRef.current;
|
|
41
|
+
}
|
|
42
|
+
const timeUntilExpiry = expiresAtRef.current
|
|
43
|
+
? Math.floor((expiresAtRef.current - Date.now()) / 1000)
|
|
44
|
+
: 0;
|
|
45
|
+
log('Token expired or expiring soon (in %ds), refreshing...', timeUntilExpiry);
|
|
46
|
+
refreshPromiseRef.current = (async () => {
|
|
38
47
|
const cfg = await getConfigRef.current();
|
|
39
|
-
setConfig(cfg);
|
|
40
48
|
tokenRef.current = cfg.token;
|
|
41
|
-
expiresAtRef.current =
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
await ensureValidToken();
|
|
48
|
-
return target.send(command);
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
return target[prop];
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
setClient(wrappedClient);
|
|
55
|
-
const decoded = jwtDecode(cfg.token);
|
|
56
|
-
expiresAtRef.current = decoded.exp * 1000;
|
|
57
|
-
const newExpiry = Math.floor((decoded.exp * 1000 - Date.now()) / 1000);
|
|
58
|
-
log('Token refreshed successfully (new token expires in %ds)', newExpiry);
|
|
49
|
+
expiresAtRef.current = cfg.expiresAt ?? (Date.now() + 900000);
|
|
50
|
+
const newExpiry = Math.floor((expiresAtRef.current - Date.now()) / 1000);
|
|
51
|
+
log('Token refreshed (expires in %ds)', newExpiry);
|
|
52
|
+
})();
|
|
53
|
+
try {
|
|
54
|
+
await refreshPromiseRef.current;
|
|
59
55
|
}
|
|
60
56
|
catch (err) {
|
|
61
57
|
log('Token refresh failed: %s', err instanceof Error ? err.message : 'Unknown error');
|
|
62
58
|
setError(err instanceof Error ? err.message : 'Failed to refresh token');
|
|
63
59
|
}
|
|
60
|
+
finally {
|
|
61
|
+
refreshPromiseRef.current = null;
|
|
62
|
+
}
|
|
64
63
|
}, [isTokenExpired]);
|
|
65
|
-
//
|
|
64
|
+
// Keep ensureValidToken ref current so the send wrapper always uses the latest version
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
ensureValidTokenRef.current = ensureValidToken;
|
|
67
|
+
}, [ensureValidToken]);
|
|
68
|
+
// Initial load — create the client once.
|
|
69
|
+
// getToken is passed as a callback so the client always reads the live token ref.
|
|
70
|
+
// ensureValidToken is called before each send so expiring tokens are refreshed
|
|
71
|
+
// transparently without recreating the client or causing re-renders.
|
|
66
72
|
useEffect(() => {
|
|
67
73
|
log('Initializing LocationClientProvider');
|
|
68
|
-
|
|
69
|
-
getConfig()
|
|
74
|
+
getConfigRef.current()
|
|
70
75
|
.then((cfg) => {
|
|
71
|
-
setConfig(cfg);
|
|
72
76
|
tokenRef.current = cfg.token;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
setClient(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
expiresAtRef.current = decoded.exp * 1000;
|
|
89
|
-
const expiry = Math.floor((decoded.exp * 1000 - Date.now()) / 1000);
|
|
90
|
-
log('Client initialized (token expires in %ds)', expiry);
|
|
91
|
-
}
|
|
92
|
-
catch (err) {
|
|
93
|
-
log('Failed to decode token, no expiry info');
|
|
94
|
-
}
|
|
77
|
+
expiresAtRef.current = cfg.expiresAt ?? (Date.now() + 900000);
|
|
78
|
+
// GeoPlacesClient.send() calls getToken() synchronously for the token value,
|
|
79
|
+
// but token refresh is async. We create a thin send wrapper that:
|
|
80
|
+
// 1. awaits ensureValidToken (via ref — always latest, race-safe)
|
|
81
|
+
// 2. delegates to baseClient which reads the now-fresh token from getToken()
|
|
82
|
+
// The client instance is created once and never recreated on token refresh.
|
|
83
|
+
const baseClient = new GeoPlacesClient({ apiUrl: cfg.apiUrl, token: cfg.token, getToken });
|
|
84
|
+
const wrappingClient = Object.create(baseClient);
|
|
85
|
+
wrappingClient.send = async (command) => {
|
|
86
|
+
await ensureValidTokenRef.current();
|
|
87
|
+
return baseClient.send(command);
|
|
88
|
+
};
|
|
89
|
+
setClient(wrappingClient);
|
|
90
|
+
const expiry = Math.floor((expiresAtRef.current - Date.now()) / 1000);
|
|
91
|
+
log('Client initialized (token expires in %ds)', expiry);
|
|
95
92
|
setLoading(false);
|
|
96
93
|
})
|
|
97
94
|
.catch((err) => {
|
|
@@ -99,14 +96,8 @@ export function LocationClientProvider({ children, getConfig, refreshBuffer = 60
|
|
|
99
96
|
setError(err instanceof Error ? err.message : 'Failed to initialize client');
|
|
100
97
|
setLoading(false);
|
|
101
98
|
});
|
|
102
|
-
}, [
|
|
103
|
-
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
if (!loading && config) {
|
|
106
|
-
ensureValidToken();
|
|
107
|
-
}
|
|
108
|
-
}, [config, loading, ensureValidToken]);
|
|
109
|
-
return (_jsx(LocationClientContext.Provider, { value: { config, client, getToken, ensureValidToken, loading, error }, children: children }));
|
|
99
|
+
}, [getToken]);
|
|
100
|
+
return (_jsx(LocationClientContext.Provider, { value: { client, getToken, loading, error }, children: children }));
|
|
110
101
|
}
|
|
111
102
|
export function useLocationClient() {
|
|
112
103
|
const context = useContext(LocationClientContext);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chaosity/location-client-react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "React bindings for Chaosity Location Service client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc",
|
|
10
10
|
"dev": "tsc --watch",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
11
13
|
"prepublishOnly": "npm run build"
|
|
12
14
|
},
|
|
13
15
|
"keywords": [
|
|
@@ -28,18 +30,24 @@
|
|
|
28
30
|
"url": "https://github.com/chaosity-io/location-service-client-react/issues"
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
31
|
-
"@chaosity/location-client": "
|
|
32
|
-
"debug": "^4.4.3"
|
|
33
|
-
"jwt-decode": "^4.0.0"
|
|
33
|
+
"@chaosity/location-client": "file:../location-service-client",
|
|
34
|
+
"debug": "^4.4.3"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
37
|
"react": "^18.0.0 || ^19.0.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
40
|
+
"@testing-library/react": "^16.0.0",
|
|
41
|
+
"@testing-library/user-event": "^14.0.0",
|
|
39
42
|
"@types/debug": "^4.1.12",
|
|
40
43
|
"@types/react": "^19.0.0",
|
|
44
|
+
"happy-dom": "^17.0.0",
|
|
41
45
|
"react": "^19.0.0",
|
|
42
|
-
"typescript": "^5.0.0"
|
|
46
|
+
"typescript": "^5.0.0",
|
|
47
|
+
"vitest": "^3.0.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
43
51
|
},
|
|
44
52
|
"files": [
|
|
45
53
|
"dist",
|