@chaosity/location-client-react 0.7.0 → 0.9.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.
|
@@ -13,6 +13,9 @@ const react_1 = require("react");
|
|
|
13
13
|
const log = (0, debug_1.default)('location-client-react:provider');
|
|
14
14
|
const LocationClientContext = (0, react_1.createContext)(undefined);
|
|
15
15
|
const DEFAULT_LIFETIME_MS = 900000;
|
|
16
|
+
/** The backoff after an attempt that brought no usable token (#34, #35, #36). */
|
|
17
|
+
const RETRY_BASE_MS = 1000;
|
|
18
|
+
const RETRY_CAP_MS = 30000;
|
|
16
19
|
/**
|
|
17
20
|
* When this token needs replacing.
|
|
18
21
|
*
|
|
@@ -26,213 +29,393 @@ function expiryOf(cfg) {
|
|
|
26
29
|
cfg.expiresAt ??
|
|
27
30
|
Date.now() + DEFAULT_LIFETIME_MS);
|
|
28
31
|
}
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
/**
|
|
33
|
+
* How long to wait after the `strikes`-th attempt in a row that brought no
|
|
34
|
+
* usable token. Exponential and capped, with full jitter so that the tabs one
|
|
35
|
+
* outage hit together do not all come back together, and never under the
|
|
36
|
+
* base: a token route that fails fast is still asked at most once a second.
|
|
37
|
+
*/
|
|
38
|
+
function backoffMs(strikes) {
|
|
39
|
+
const ceiling = Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** strikes);
|
|
40
|
+
return Math.max(RETRY_BASE_MS, Math.random() * ceiling);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The wait a failure asked for: the core's `LocationServiceException` carries
|
|
44
|
+
* a 429's or 503's `Retry-After` as `retryAfterMs`.
|
|
45
|
+
*/
|
|
46
|
+
function retryAfterOf(err) {
|
|
47
|
+
const ms = err
|
|
48
|
+
?.retryAfterMs;
|
|
49
|
+
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
|
50
|
+
}
|
|
51
|
+
function overrides(trigger, hold) {
|
|
52
|
+
if (trigger === 'scheduled')
|
|
53
|
+
return true;
|
|
54
|
+
if (hold.retryAfter)
|
|
55
|
+
return false;
|
|
56
|
+
if (trigger === 'user')
|
|
57
|
+
return true;
|
|
58
|
+
return trigger === 'rejected' && hold.error === null;
|
|
59
|
+
}
|
|
60
|
+
function isStale(state) {
|
|
61
|
+
if (!state.expiresAt)
|
|
62
|
+
return true;
|
|
63
|
+
return Date.now() >= state.expiresAt - location_client_1.TOKEN_REFRESH_BUFFER_SECONDS * 1000;
|
|
64
|
+
}
|
|
65
|
+
function newConfig(key, refs) {
|
|
66
|
+
const state = {
|
|
67
|
+
key,
|
|
68
|
+
token: undefined,
|
|
69
|
+
expiresAt: null,
|
|
70
|
+
attempt: null,
|
|
71
|
+
timer: null,
|
|
72
|
+
hold: null,
|
|
73
|
+
strikes: 0,
|
|
74
|
+
session: null,
|
|
75
|
+
/**
|
|
76
|
+
* If the token is already stale — a timer that never fired because the
|
|
77
|
+
* tab was backgrounded and throttled — this kicks off a refresh but cannot
|
|
78
|
+
* wait for it. The current read still returns the stale value; the point
|
|
79
|
+
* is that the NEXT one will not. While a hold stands, the refresh it asks
|
|
80
|
+
* for is refused.
|
|
81
|
+
*/
|
|
82
|
+
getToken: () => {
|
|
83
|
+
if (refs.config.current !== state)
|
|
84
|
+
return undefined;
|
|
85
|
+
// `state.token` guards the pre-initialisation window: until the first
|
|
86
|
+
// config load lands there is no expiry to judge, and firing here would
|
|
87
|
+
// race the initial fetch and request a second token nobody asked for.
|
|
88
|
+
if (state.token && isStale(state) && !state.attempt) {
|
|
89
|
+
void refs.refresh.current('auto').catch(() => { });
|
|
90
|
+
}
|
|
91
|
+
return state.token;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
const replacedError = () => new Error('This location client belongs to a configuration LocationClientProvider has since replaced or unmounted. Use the one useLocationClient() returns now.');
|
|
97
|
+
function LocationClientProvider({ children, getConfig, configKey, }) {
|
|
98
|
+
const getConfigRef = (0, react_1.useRef)(getConfig);
|
|
99
|
+
// The installed configuration: null only between one being disposed and the
|
|
100
|
+
// next installed, and after unmount.
|
|
101
|
+
const configRef = (0, react_1.useRef)(null);
|
|
102
|
+
// `refresh` is reached from the timer, from each configuration's `getToken`
|
|
103
|
+
// and from the functions every client is built with; a ref breaks the cycle
|
|
104
|
+
// without recreating any of them.
|
|
105
|
+
const refreshRef = (0, react_1.useRef)(() => Promise.resolve());
|
|
106
|
+
const refs = (0, react_1.useMemo)(() => ({ config: configRef, refresh: refreshRef }), []);
|
|
107
|
+
const [config, setConfig] = (0, react_1.useState)(() => newConfig(configKey, refs));
|
|
108
|
+
const [session, setSession] = (0, react_1.useState)(null);
|
|
31
109
|
const [loading, setLoading] = (0, react_1.useState)(true);
|
|
32
110
|
const [error, setError] = (0, react_1.useState)(null);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
111
|
+
// A new key is a new configuration in the very render that carries it, not
|
|
112
|
+
// one render later from an effect: no child is handed the old client, and a
|
|
113
|
+
// map built now gets the `getToken` that will read the new token. React's
|
|
114
|
+
// pattern for adjusting state when a prop changes; `Object.is`, because
|
|
115
|
+
// `NaN !== NaN` would loop forever.
|
|
116
|
+
if (!Object.is(config.key, configKey)) {
|
|
117
|
+
setConfig(newConfig(configKey, refs));
|
|
118
|
+
setSession(null);
|
|
119
|
+
setLoading(true);
|
|
120
|
+
setError(null);
|
|
121
|
+
}
|
|
39
122
|
(0, react_1.useEffect)(() => {
|
|
40
123
|
getConfigRef.current = getConfig;
|
|
41
124
|
}, [getConfig]);
|
|
42
|
-
const isTokenExpired = (0, react_1.useCallback)(() => {
|
|
43
|
-
if (!expiresAtRef.current)
|
|
44
|
-
return true;
|
|
45
|
-
return (Date.now() >= expiresAtRef.current - location_client_1.TOKEN_REFRESH_BUFFER_SECONDS * 1000);
|
|
46
|
-
}, []);
|
|
47
125
|
/**
|
|
48
|
-
*
|
|
126
|
+
* Arm the timer for the next attempt: ahead of expiry, or when a hold ends.
|
|
49
127
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
128
|
+
* Refreshing AHEAD of expiry is the whole fix for the map path. MapLibre's
|
|
129
|
+
* `transformRequest` is synchronous by contract, so `getToken` cannot await
|
|
130
|
+
* anything — the token it reads has to be valid already. Refresh used to
|
|
131
|
+
* happen only inside the `send` wrapper, which the map never calls: it
|
|
132
|
+
* requests tiles, glyphs and sprites directly. So after 15 minutes every map
|
|
133
|
+
* request failed, for as long as the page stayed open, and no amount of
|
|
134
|
+
* panning recovered it.
|
|
54
135
|
*/
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
})();
|
|
66
|
-
try {
|
|
67
|
-
await refreshPromiseRef.current;
|
|
68
|
-
scheduleRefreshRef.current();
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
const message = err instanceof Error ? err.message : 'Failed to refresh token';
|
|
72
|
-
log('Token refresh failed: %s', message);
|
|
73
|
-
if (mountedRef.current)
|
|
74
|
-
setError(message);
|
|
75
|
-
throw err;
|
|
76
|
-
}
|
|
77
|
-
finally {
|
|
78
|
-
refreshPromiseRef.current = null;
|
|
79
|
-
}
|
|
136
|
+
const schedule = (0, react_1.useCallback)((state, at) => {
|
|
137
|
+
if (state.timer)
|
|
138
|
+
clearTimeout(state.timer);
|
|
139
|
+
const delay = Math.max(0, at - Date.now());
|
|
140
|
+
log('Next attempt in %ds', Math.floor(delay / 1000));
|
|
141
|
+
state.timer = setTimeout(() => {
|
|
142
|
+
// Failures are already surfaced onto state by refresh; swallow here so
|
|
143
|
+
// a failed background attempt cannot become an unhandled rejection.
|
|
144
|
+
void refreshRef.current('scheduled').catch(() => { });
|
|
145
|
+
}, delay);
|
|
80
146
|
}, []);
|
|
81
147
|
/**
|
|
82
|
-
*
|
|
148
|
+
* The client for one configuration's `apiUrl`.
|
|
83
149
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* directly. So after 15 minutes every map request failed, for as long as the
|
|
89
|
-
* page stayed open, and no amount of panning recovered it.
|
|
150
|
+
* Everything built here checks that its configuration is still the live one,
|
|
151
|
+
* and refuses once it is not (#14). Reading the provider's token regardless
|
|
152
|
+
* was the defect: a client kept across a switch sent the new configuration's
|
|
153
|
+
* token to its old URL.
|
|
90
154
|
*/
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
155
|
+
const build = (0, react_1.useCallback)((state, apiUrl) => {
|
|
156
|
+
// `session` is declared at the end; these only run after it exists.
|
|
157
|
+
const isLive = () => configRef.current === state && state.session === session;
|
|
158
|
+
/**
|
|
159
|
+
* The pre-send refresh. While #35's floor holds it resolves, and the send
|
|
160
|
+
* goes out with the token the server has just handed back.
|
|
161
|
+
*
|
|
162
|
+
* When the refresh fails, or a failed one holds (#36), the send still goes
|
|
163
|
+
* out with the token in hand for as long as that token is before its own
|
|
164
|
+
* `exp`: the API accepts it, and the map is sending it for every tile, so
|
|
165
|
+
* refusing a `send` then made the two paths disagree (Mehdi, 26 Sep 2026).
|
|
166
|
+
* Past `exp` it rejects with the refresh error, so the consumer learns the
|
|
167
|
+
* token endpoint is down rather than being told the API refused them.
|
|
168
|
+
* That is the line the provider's old behaviour missed: it swallowed the
|
|
169
|
+
* failure and sent whatever it held, expired or not.
|
|
170
|
+
*/
|
|
171
|
+
const ready = async () => {
|
|
172
|
+
if (!isLive())
|
|
173
|
+
throw replacedError();
|
|
174
|
+
if (isStale(state)) {
|
|
175
|
+
try {
|
|
176
|
+
await refreshRef.current('auto');
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// A replaced client is refused on the line after this block.
|
|
180
|
+
const usable = state.token !== undefined &&
|
|
181
|
+
state.expiresAt !== null &&
|
|
182
|
+
Date.now() < state.expiresAt;
|
|
183
|
+
if (!usable)
|
|
184
|
+
throw err;
|
|
185
|
+
log('Refresh failed; sending with the token in hand until its exp');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!isLive())
|
|
189
|
+
throw replacedError();
|
|
190
|
+
};
|
|
191
|
+
const baseClient = new location_client_1.GeoPlacesClient({
|
|
192
|
+
apiUrl,
|
|
193
|
+
// No static `token`: the core falls back to it whenever `getToken` has
|
|
194
|
+
// nothing, and here that means this client has been replaced.
|
|
195
|
+
getToken: () => (isLive() ? state.getToken() : undefined),
|
|
196
|
+
/**
|
|
197
|
+
* The 401 escape hatch (#19).
|
|
198
|
+
*
|
|
199
|
+
* Covers what the timer cannot: a token revoked from the portal, or
|
|
200
|
+
* minted against a client secret since rotated, is refused by the API
|
|
201
|
+
* while still minutes from its own `exp` — so nothing on this side has
|
|
202
|
+
* any reason to replace it, and every request fails until the buffer
|
|
203
|
+
* finally comes around. `getToken` cannot help, being synchronous.
|
|
204
|
+
*
|
|
205
|
+
* The client awaits this after a 401 and retries the request once with
|
|
206
|
+
* what it returns; the same token, or nothing, means no retry, so a
|
|
207
|
+
* doomed request is never sent — or billed — twice.
|
|
208
|
+
*
|
|
209
|
+
* @chaosity/location-client 0.8.0 and later also calls it BEFORE the
|
|
210
|
+
* first send when it holds no token at all. Here that means this client
|
|
211
|
+
* has been replaced, which is refused below, or that `getConfig`
|
|
212
|
+
* answered without a token, which is asked for again as after a 401.
|
|
213
|
+
*
|
|
214
|
+
* It REJECTS when the refresh itself fails, and that is left to
|
|
215
|
+
* propagate out of `send` deliberately: the consumer learns the token
|
|
216
|
+
* endpoint is down rather than being told the API rejected them. Unlike
|
|
217
|
+
* the pre-flight path in `ready`, it never falls back to the token in
|
|
218
|
+
* hand: the API has just refused that token.
|
|
219
|
+
*/
|
|
220
|
+
refreshToken: async () => {
|
|
221
|
+
if (!isLive())
|
|
222
|
+
throw replacedError();
|
|
223
|
+
await refreshRef.current('rejected');
|
|
224
|
+
if (!isLive())
|
|
225
|
+
throw replacedError();
|
|
226
|
+
return state.token;
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
// A plain object, not Object.create(baseClient): the prototype hack was
|
|
230
|
+
// opaque, and its `send` dropped the second argument entirely — so once
|
|
231
|
+
// the client gained `signal`/`timeoutMs`, every option passed through
|
|
232
|
+
// this provider would have been silently discarded.
|
|
233
|
+
const client = {
|
|
234
|
+
config: baseClient.config,
|
|
235
|
+
async send(command, options) {
|
|
236
|
+
await ready();
|
|
237
|
+
return baseClient.send(command, options);
|
|
238
|
+
},
|
|
239
|
+
// Behind the same pre-send refresh as `send`: forwarding it bare
|
|
240
|
+
// would send a stale token that `send` would have replaced (#26).
|
|
241
|
+
async verifyAddress(placeId, options) {
|
|
242
|
+
await ready();
|
|
243
|
+
return baseClient.verifyAddress(placeId, options);
|
|
244
|
+
},
|
|
245
|
+
// Reads whatever token the client currently holds. Deliberately not
|
|
246
|
+
// awaiting a refresh: this is display data, callers expect it to be
|
|
247
|
+
// synchronous, and a token that is minutes from expiry carries the
|
|
248
|
+
// same application config as its replacement will. Once this client is
|
|
249
|
+
// replaced it answers `{}`: the core reads through the bound `getToken`.
|
|
250
|
+
getAppConfig() {
|
|
251
|
+
return baseClient.getAppConfig();
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
const session = { client, apiUrl };
|
|
255
|
+
return session;
|
|
256
|
+
}, []);
|
|
110
257
|
/**
|
|
111
|
-
*
|
|
258
|
+
* The one place `getConfig` is called: the first load, every refresh and
|
|
259
|
+
* every retry. Once at a time, however many callers ask at the same moment.
|
|
260
|
+
*
|
|
261
|
+
* REJECTS on failure. It used to swallow the error into state and resolve,
|
|
262
|
+
* so `send` carried on with the token it already had — guaranteeing a 401 on
|
|
263
|
+
* the very next call and reporting it as an API error rather than a refresh
|
|
264
|
+
* failure.
|
|
112
265
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
266
|
+
* Every outcome now decides when the next attempt may start (see `Hold`).
|
|
267
|
+
* Nothing used to: a failed first load scheduled no retry at all and left
|
|
268
|
+
* `client` null for the rest of the page view (#34); a failed refresh let
|
|
269
|
+
* every stale read ask again at once (#36); and a token already stale by
|
|
270
|
+
* this clock was asked for again as soon as it arrived (#35). The first load
|
|
271
|
+
* was also the only call that could build a client. Now whichever attempt
|
|
272
|
+
* succeeds first builds it.
|
|
117
273
|
*/
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
274
|
+
const refresh = (0, react_1.useCallback)((trigger) => {
|
|
275
|
+
const state = configRef.current;
|
|
276
|
+
if (!state)
|
|
277
|
+
return Promise.reject(replacedError());
|
|
278
|
+
if (state.attempt)
|
|
279
|
+
return state.attempt;
|
|
280
|
+
const { hold } = state;
|
|
281
|
+
if (hold && Date.now() < hold.until && !overrides(trigger, hold)) {
|
|
282
|
+
return hold.error === null
|
|
283
|
+
? Promise.resolve()
|
|
284
|
+
: Promise.reject(hold.error);
|
|
125
285
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
286
|
+
log('Asking getConfig (%s)', trigger);
|
|
287
|
+
const attempt = (async () => {
|
|
288
|
+
let cfg;
|
|
289
|
+
try {
|
|
290
|
+
cfg = await getConfigRef.current();
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
if (configRef.current !== state)
|
|
294
|
+
throw replacedError();
|
|
295
|
+
state.strikes += 1;
|
|
296
|
+
const retryAfterMs = retryAfterOf(err);
|
|
297
|
+
state.hold = {
|
|
298
|
+
until: Date.now() + (retryAfterMs ?? backoffMs(state.strikes)),
|
|
299
|
+
error: err,
|
|
300
|
+
retryAfter: retryAfterMs !== undefined,
|
|
301
|
+
};
|
|
302
|
+
const message = err instanceof Error
|
|
303
|
+
? err.message
|
|
304
|
+
: state.session
|
|
305
|
+
? 'Failed to refresh token'
|
|
306
|
+
: 'Failed to initialize client';
|
|
307
|
+
log('%s failed: %s', state.session ? 'Token refresh' : 'Initialization', message);
|
|
308
|
+
setError(message);
|
|
309
|
+
setLoading(false);
|
|
310
|
+
schedule(state, state.hold.until);
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
if (configRef.current !== state)
|
|
314
|
+
throw replacedError();
|
|
315
|
+
// An answer naming another API is another configuration (#14), even
|
|
316
|
+
// under the same key: start it afresh, exactly as a `configKey` change
|
|
317
|
+
// would. Its token is NOT taken here, because every map and client of
|
|
318
|
+
// this configuration would pair it with the old URL until the switch
|
|
319
|
+
// lands.
|
|
320
|
+
if (state.session && state.session.apiUrl !== cfg.apiUrl) {
|
|
321
|
+
log('getConfig moved to %s — starting afresh', cfg.apiUrl);
|
|
322
|
+
state.session = null;
|
|
323
|
+
setConfig(newConfig(state.key, refs));
|
|
324
|
+
setSession(null);
|
|
325
|
+
setLoading(true);
|
|
326
|
+
setError(null);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
state.token = cfg.token;
|
|
330
|
+
state.expiresAt = expiryOf(cfg);
|
|
331
|
+
if (isStale(state)) {
|
|
332
|
+
// Stale on arrival (#35): the server judges this token fresh by its
|
|
333
|
+
// clock, this browser judges it stale by its own. Asking again at
|
|
334
|
+
// once brings the same answer, as fast as `getConfig` can give it,
|
|
335
|
+
// so wait — longer each time it happens again.
|
|
336
|
+
state.strikes += 1;
|
|
337
|
+
state.hold = {
|
|
338
|
+
until: Date.now() + backoffMs(state.strikes),
|
|
339
|
+
error: null,
|
|
340
|
+
retryAfter: false,
|
|
341
|
+
};
|
|
342
|
+
log('Token arrived already stale by this clock');
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
state.strikes = 0;
|
|
346
|
+
state.hold = null;
|
|
347
|
+
}
|
|
348
|
+
if (!state.session) {
|
|
349
|
+
state.session = build(state, cfg.apiUrl);
|
|
350
|
+
setSession(state.session);
|
|
351
|
+
}
|
|
352
|
+
log('Token refreshed (expires in %ds)', Math.floor((state.expiresAt - Date.now()) / 1000));
|
|
353
|
+
setError(null);
|
|
354
|
+
setLoading(false);
|
|
355
|
+
schedule(state, Math.max(state.expiresAt - location_client_1.TOKEN_REFRESH_BUFFER_SECONDS * 1000, state.hold?.until ?? 0));
|
|
356
|
+
})();
|
|
357
|
+
state.attempt = attempt;
|
|
358
|
+
// Clears only its own slot: the next configuration has its own.
|
|
359
|
+
const settle = () => {
|
|
360
|
+
if (state.attempt === attempt)
|
|
361
|
+
state.attempt = null;
|
|
362
|
+
};
|
|
363
|
+
attempt.then(settle, settle);
|
|
364
|
+
return attempt;
|
|
365
|
+
}, [build, refs, schedule]);
|
|
134
366
|
(0, react_1.useEffect)(() => {
|
|
135
|
-
|
|
136
|
-
}, [
|
|
367
|
+
refreshRef.current = refresh;
|
|
368
|
+
}, [refresh]);
|
|
137
369
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
370
|
+
* The tab or the network coming back.
|
|
371
|
+
*
|
|
372
|
+
* A backgrounded tab has its timers throttled, so the scheduled refresh can
|
|
373
|
+
* be arbitrarily late: refresh on the way back in, before the user touches
|
|
374
|
+
* the map. And a first load that failed gets its retry now, rather than when
|
|
375
|
+
* the backoff comes round (#34).
|
|
140
376
|
*/
|
|
141
377
|
(0, react_1.useEffect)(() => {
|
|
142
378
|
if (typeof document === 'undefined')
|
|
143
379
|
return;
|
|
380
|
+
const retry = () => {
|
|
381
|
+
const state = configRef.current;
|
|
382
|
+
if (!state || (state.token && !isStale(state)))
|
|
383
|
+
return;
|
|
384
|
+
log('Back, with no fresh token — asking now');
|
|
385
|
+
void refreshRef.current('user').catch(() => { });
|
|
386
|
+
};
|
|
144
387
|
const onVisible = () => {
|
|
145
|
-
if (document.visibilityState === 'visible'
|
|
146
|
-
|
|
147
|
-
isTokenExpired()) {
|
|
148
|
-
log('Tab visible again with a stale token — refreshing');
|
|
149
|
-
void refreshToken().catch(() => { });
|
|
150
|
-
}
|
|
388
|
+
if (document.visibilityState === 'visible')
|
|
389
|
+
retry();
|
|
151
390
|
};
|
|
152
391
|
document.addEventListener('visibilitychange', onVisible);
|
|
153
|
-
|
|
154
|
-
|
|
392
|
+
window.addEventListener('online', retry);
|
|
393
|
+
return () => {
|
|
394
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
395
|
+
window.removeEventListener('online', retry);
|
|
396
|
+
};
|
|
397
|
+
}, []);
|
|
398
|
+
// One configuration at a time, installed here and disposed with it.
|
|
155
399
|
(0, react_1.useEffect)(() => {
|
|
156
|
-
mountedRef.current = true;
|
|
157
400
|
log('Initializing LocationClientProvider');
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
.then((cfg) => {
|
|
161
|
-
if (!mountedRef.current)
|
|
162
|
-
return;
|
|
163
|
-
tokenRef.current = cfg.token;
|
|
164
|
-
expiresAtRef.current = expiryOf(cfg);
|
|
165
|
-
const baseClient = new location_client_1.GeoPlacesClient({
|
|
166
|
-
apiUrl: cfg.apiUrl,
|
|
167
|
-
token: cfg.token,
|
|
168
|
-
getToken,
|
|
169
|
-
/**
|
|
170
|
-
* The 401 escape hatch (#19).
|
|
171
|
-
*
|
|
172
|
-
* Covers what the timer cannot: a token revoked from the portal, or
|
|
173
|
-
* minted against a client secret since rotated, is refused by the API
|
|
174
|
-
* while still minutes from its own `exp` — so nothing on this side has
|
|
175
|
-
* any reason to replace it, and every request fails until the buffer
|
|
176
|
-
* finally comes around. `getToken` cannot help, being synchronous.
|
|
177
|
-
*
|
|
178
|
-
* The client awaits this after a 401 and retries the request once with
|
|
179
|
-
* what it returns; the same token, or nothing, means no retry, so a
|
|
180
|
-
* doomed request is never sent — or billed — twice.
|
|
181
|
-
*
|
|
182
|
-
* It REJECTS when the refresh itself fails, and that is left to
|
|
183
|
-
* propagate out of `send` deliberately: the consumer learns the token
|
|
184
|
-
* endpoint is down rather than being told the API rejected them. Same
|
|
185
|
-
* answer the pre-flight `ensureValidToken` path already gives.
|
|
186
|
-
*/
|
|
187
|
-
refreshToken: async () => {
|
|
188
|
-
await refreshToken();
|
|
189
|
-
return tokenRef.current;
|
|
190
|
-
},
|
|
191
|
-
});
|
|
192
|
-
// A plain object, not Object.create(baseClient): the prototype hack was
|
|
193
|
-
// opaque, and its `send` dropped the second argument entirely — so once
|
|
194
|
-
// the client gained `signal`/`timeoutMs`, every option passed through
|
|
195
|
-
// this provider would have been silently discarded.
|
|
196
|
-
const refreshing = {
|
|
197
|
-
config: baseClient.config,
|
|
198
|
-
async send(command, options) {
|
|
199
|
-
await ensureValidTokenRef.current();
|
|
200
|
-
return baseClient.send(command, options);
|
|
201
|
-
},
|
|
202
|
-
// Behind the same pre-send refresh as `send`: forwarding it bare
|
|
203
|
-
// would send a stale token that `send` would have replaced (#26).
|
|
204
|
-
async verifyAddress(placeId, options) {
|
|
205
|
-
await ensureValidTokenRef.current();
|
|
206
|
-
return baseClient.verifyAddress(placeId, options);
|
|
207
|
-
},
|
|
208
|
-
// Reads whatever token the client currently holds. Deliberately not
|
|
209
|
-
// awaiting a refresh: this is display data, callers expect it to be
|
|
210
|
-
// synchronous, and a token that is minutes from expiry carries the
|
|
211
|
-
// same application config as its replacement will.
|
|
212
|
-
getAppConfig() {
|
|
213
|
-
return baseClient.getAppConfig();
|
|
214
|
-
},
|
|
215
|
-
};
|
|
216
|
-
setClient(refreshing);
|
|
217
|
-
scheduleRefreshRef.current();
|
|
218
|
-
log('Client initialized (token expires in %ds)', Math.floor((expiresAtRef.current - Date.now()) / 1000));
|
|
219
|
-
setLoading(false);
|
|
220
|
-
})
|
|
221
|
-
.catch((err) => {
|
|
222
|
-
if (!mountedRef.current)
|
|
223
|
-
return;
|
|
224
|
-
const message = err instanceof Error ? err.message : 'Failed to initialize client';
|
|
225
|
-
log('Initialization failed: %s', message);
|
|
226
|
-
setError(message);
|
|
227
|
-
setLoading(false);
|
|
228
|
-
});
|
|
401
|
+
configRef.current = config;
|
|
402
|
+
void refresh('scheduled').catch(() => { });
|
|
229
403
|
return () => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
404
|
+
// An answer still in flight finds the configuration gone, and writes
|
|
405
|
+
// nothing; every client and `getToken` of it refuses from now on.
|
|
406
|
+
if (config.timer)
|
|
407
|
+
clearTimeout(config.timer);
|
|
408
|
+
configRef.current = null;
|
|
233
409
|
};
|
|
234
|
-
}, [
|
|
235
|
-
|
|
410
|
+
}, [config, refresh]);
|
|
411
|
+
const value = (0, react_1.useMemo)(() => ({
|
|
412
|
+
client: session?.client ?? null,
|
|
413
|
+
getToken: config.getToken,
|
|
414
|
+
apiUrl: session?.apiUrl ?? null,
|
|
415
|
+
loading,
|
|
416
|
+
error,
|
|
417
|
+
}), [config, session, loading, error]);
|
|
418
|
+
return ((0, jsx_runtime_1.jsx)(LocationClientContext.Provider, { value: value, children: children }));
|
|
236
419
|
}
|
|
237
420
|
function useLocationClient() {
|
|
238
421
|
const context = (0, react_1.useContext)(LocationClientContext);
|
|
@@ -73,6 +73,14 @@ export interface LocationClient {
|
|
|
73
73
|
interface LocationClientContextValue {
|
|
74
74
|
client: LocationClient | null;
|
|
75
75
|
getToken: () => string | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* The API `client` talks to, from the same `getConfig` answer as the token
|
|
78
|
+
* `getToken` returns (#14). Build a map's style and tile URLs from this
|
|
79
|
+
* rather than restating the URL, so a map and its token cannot come from two
|
|
80
|
+
* different configurations. `null` until a configuration has loaded, and
|
|
81
|
+
* again while a new `configKey` loads.
|
|
82
|
+
*/
|
|
83
|
+
apiUrl: string | null;
|
|
76
84
|
loading: boolean;
|
|
77
85
|
error: string | null;
|
|
78
86
|
}
|
|
@@ -81,7 +89,20 @@ export interface LocationClientProviderProps {
|
|
|
81
89
|
getConfig: () => Promise<ClientConfig & {
|
|
82
90
|
expiresAt?: number;
|
|
83
91
|
}>;
|
|
92
|
+
/**
|
|
93
|
+
* What `getConfig` answers for: an organisation or application id.
|
|
94
|
+
*
|
|
95
|
+
* Changing it drops the old configuration's token, client and `apiUrl` in
|
|
96
|
+
* the same render, and asks `getConfig` again, without remounting the
|
|
97
|
+
* children (#14). A client or a `getToken` kept from before the change
|
|
98
|
+
* refuses from then on, rather than pairing one configuration's token with
|
|
99
|
+
* the other's URL.
|
|
100
|
+
*
|
|
101
|
+
* `getConfig`'s own identity is deliberately not such a signal: it is
|
|
102
|
+
* usually an inline function, new on every render.
|
|
103
|
+
*/
|
|
104
|
+
configKey?: string | number;
|
|
84
105
|
}
|
|
85
|
-
export declare function LocationClientProvider({ children, getConfig, }: LocationClientProviderProps): import("react").JSX.Element;
|
|
106
|
+
export declare function LocationClientProvider({ children, getConfig, configKey, }: LocationClientProviderProps): import("react").JSX.Element;
|
|
86
107
|
export declare function useLocationClient(): LocationClientContextValue;
|
|
87
108
|
export {};
|