@chaosity/location-client 0.13.0 → 0.13.1

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
@@ -252,7 +252,14 @@ tiles receive the new token no other way — they read `getToken` through
252
252
  `createTransformRequest` — so a `refreshToken` that leaves `getToken` unchanged
253
253
  reloads nothing. A 403 is never retried: a new token cannot change it. A token
254
254
  `refreshToken` could not replace is not asked about again for 30 seconds, by
255
- any helper handed the same `tokens` object. `stop()` removes the listener.
255
+ any helper handed the same `tokens` object. For 30 seconds after a refresh
256
+ brought a new token, a refused tile is reloaded once with it rather than asked
257
+ about: a tile requested with the old token can be refused after the new one
258
+ arrived. A tile the helper reloaded with the new token, refused again, means
259
+ the API refuses that token too, and it is held like the first. So an API that
260
+ refuses every token, such as a map pointed at an API its tokens are not for,
261
+ costs one new token per 30 seconds rather than one per reload, however many
262
+ maps share the `tokens` object. `stop()` removes the listener.
256
263
 
257
264
  A bare `getToken` works as it always has: one request, and its 401 to you.
258
265
 
@@ -66,8 +66,11 @@ export interface TokenRefreshMap {
66
66
  *
67
67
  * What it learns is held as the fetch helpers hold it, and shared with them
68
68
  * when they are handed the same `tokens` object (#38): a token `refreshToken`
69
- * could not replace, and a failure that says when to ask again, are not asked
70
- * about again until they lapse or the token in hand changes.
69
+ * could not replace, a token it brought that the API refused on a tile
70
+ * reloaded with it, and a failure that says when to ask again, are not asked
71
+ * about again until they lapse or the token in hand changes. While the token
72
+ * a refresh brought is in hand, any other refused tile is reloaded once with
73
+ * it, without asking.
71
74
  *
72
75
  * Returns a function that stops listening.
73
76
  *
@@ -19,6 +19,13 @@ function holdOf(tokens) {
19
19
  holds.set(tokens, (hold = new tokenHold_js_1.TokenHold()));
20
20
  return hold;
21
21
  }
22
+ /**
23
+ * The token each `MapTokens` object's refresh last brought, and until when a
24
+ * 401 while it is in hand is not news (#72). One per object, beside its hold:
25
+ * a note per helper had each of two helpers handed one object take the
26
+ * other's token as news, and ask again for every refusal.
27
+ */
28
+ const broughtBy = new WeakMap();
22
29
  /**
23
30
  * Send with the token in hand, and after a 401 once more with a different one
24
31
  * from `refreshToken`, all within the call's signal and deadline (#62). A 403
@@ -53,8 +60,11 @@ async function sendWithTokenRefresh(source, noTokenAdvice, call, send) {
53
60
  *
54
61
  * What it learns is held as the fetch helpers hold it, and shared with them
55
62
  * when they are handed the same `tokens` object (#38): a token `refreshToken`
56
- * could not replace, and a failure that says when to ask again, are not asked
57
- * about again until they lapse or the token in hand changes.
63
+ * could not replace, a token it brought that the API refused on a tile
64
+ * reloaded with it, and a failure that says when to ask again, are not asked
65
+ * about again until they lapse or the token in hand changes. While the token
66
+ * a refresh brought is in hand, any other refused tile is reloaded once with
67
+ * it, without asking.
58
68
  *
59
69
  * Returns a function that stops listening.
60
70
  *
@@ -70,16 +80,26 @@ async function sendWithTokenRefresh(source, noTokenAdvice, call, send) {
70
80
  function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
71
81
  const hold = holdOf(tokens);
72
82
  // Each source's refused tiles, keyed `z/x/y` so a tile refused twice is
73
- // reloaded once.
83
+ // reloaded once. Tiles refused while a hold stands are kept, so the next ask
84
+ // reloads them; MapLibre ignores ids no longer in view.
74
85
  const refused = new Map();
86
+ // The token each tile was reloaded with here, keyed `source:z/x/y`.
87
+ const reloadedWith = new Map();
75
88
  let asking = false;
89
+ const reload = (sourceId, tiles, token) => {
90
+ for (const { x, y, z } of tiles)
91
+ reloadedWith.set(`${sourceId}:${z}/${x}/${y}`, token);
92
+ map.refreshTiles(sourceId, tiles);
93
+ };
76
94
  const onError = ({ error, sourceId, tile }) => {
77
95
  if (error?.status !== 401 || !error.url || !(0, createTransformRequest_js_1.isOurApi)(error.url, apiUrl))
78
96
  return;
97
+ let refusedTile;
79
98
  if (sourceId && tile) {
80
99
  const { x, y, z } = tile.tileID.canonical;
81
100
  const tiles = refused.get(sourceId) ?? new Map();
82
101
  refused.set(sourceId, tiles.set(`${z}/${x}/${y}`, { x, y, z }));
102
+ refusedTile = [sourceId, { x, y, z }];
83
103
  }
84
104
  if (asking)
85
105
  return;
@@ -87,6 +107,32 @@ function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
87
107
  const held = hold.check(inHand);
88
108
  if (held && !held.askAgain)
89
109
  return;
110
+ // A 401 while the token a refresh brought is in hand. The event does not
111
+ // say which token the refused request carried, and a tile requested with
112
+ // the token before, answered late, looks like the new token refused. So
113
+ // only a tile reloaded here with the token in hand is that token refused:
114
+ // the API refuses every token, as it does a map pointed at an API its
115
+ // tokens are not for, and asking again minted a token and reloaded the
116
+ // tiles several times a second. That is remembered, as a send path
117
+ // remembers its retry refused, and the next ask is after the hold. Any
118
+ // other tile is reloaded once with the token in hand; anything else (a
119
+ // glyph, a sprite) asks nothing.
120
+ const brought = broughtBy.get(tokens);
121
+ if (inHand && inHand === brought?.token && Date.now() < brought.until) {
122
+ if (!refusedTile)
123
+ return;
124
+ const [id, coordinates] = refusedTile;
125
+ const key = `${coordinates.z}/${coordinates.x}/${coordinates.y}`;
126
+ if (reloadedWith.get(`${id}:${key}`) !== inHand) {
127
+ refused.get(id)?.delete(key);
128
+ reload(id, [coordinates], inHand);
129
+ return;
130
+ }
131
+ // Kept in `refused`, as every tile refused during the hold is.
132
+ broughtBy.delete(tokens);
133
+ hold.remember(unauthorized('The API refused a tile reloaded with the token refreshToken brought.'), inHand);
134
+ return;
135
+ }
90
136
  asking = true;
91
137
  // Asked inside an executor, so a `refreshToken` that throws instead of
92
138
  // rejecting lands in the catch below rather than in MapLibre's emitter.
@@ -100,15 +146,18 @@ function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
100
146
  if (!now || now === inHand) {
101
147
  // MapLibre's error carries none of the API's fields, so the refusal
102
148
  // is remembered as the 401 it was.
103
- hold.remember(new LocationServiceException_js_1.LocationServiceException({
104
- code: 'UnauthorizedException',
105
- message: 'The API refused the token, and getToken has no other since refreshToken settled.',
106
- statusCode: 401,
107
- }), inHand);
149
+ hold.remember(unauthorized('The API refused the token, and getToken has no other since refreshToken settled.'), inHand);
108
150
  return;
109
151
  }
152
+ broughtBy.set(tokens, {
153
+ token: now,
154
+ until: Date.now() + tokenHold_js_1.TOKEN_REFUSAL_HOLD_MS,
155
+ });
156
+ // What was reloaded with an earlier token can no longer match.
157
+ reloadedWith.clear();
110
158
  for (const [id, tiles] of refused)
111
- map.refreshTiles(id, [...tiles.values()]);
159
+ if (tiles.size)
160
+ reload(id, [...tiles.values()], now);
112
161
  })
113
162
  .catch((refusal) => {
114
163
  // Only a failure that says when to ask again is remembered; any other
@@ -124,3 +173,9 @@ function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
124
173
  map.on('error', onError);
125
174
  return () => map.off('error', onError);
126
175
  }
176
+ /** A refusal MapLibre reported, remembered as the 401 it was. */
177
+ const unauthorized = (message) => new LocationServiceException_js_1.LocationServiceException({
178
+ code: 'UnauthorizedException',
179
+ message,
180
+ statusCode: 401,
181
+ });
@@ -66,8 +66,11 @@ export interface TokenRefreshMap {
66
66
  *
67
67
  * What it learns is held as the fetch helpers hold it, and shared with them
68
68
  * when they are handed the same `tokens` object (#38): a token `refreshToken`
69
- * could not replace, and a failure that says when to ask again, are not asked
70
- * about again until they lapse or the token in hand changes.
69
+ * could not replace, a token it brought that the API refused on a tile
70
+ * reloaded with it, and a failure that says when to ask again, are not asked
71
+ * about again until they lapse or the token in hand changes. While the token
72
+ * a refresh brought is in hand, any other refused tile is reloaded once with
73
+ * it, without asking.
71
74
  *
72
75
  * Returns a function that stops listening.
73
76
  *
@@ -1,4 +1,4 @@
1
- import { TokenHold, holdFor, sendRetryingOnce } from '../auth/tokenHold.js';
1
+ import { TOKEN_REFUSAL_HOLD_MS, TokenHold, holdFor, sendRetryingOnce, } from '../auth/tokenHold.js';
2
2
  import { LocationServiceException } from '../errors/LocationServiceException.js';
3
3
  import { noTokenAvailable } from '../transport/errors.js';
4
4
  import { withinCall } from '../transport/http.js';
@@ -15,6 +15,13 @@ function holdOf(tokens) {
15
15
  holds.set(tokens, (hold = new TokenHold()));
16
16
  return hold;
17
17
  }
18
+ /**
19
+ * The token each `MapTokens` object's refresh last brought, and until when a
20
+ * 401 while it is in hand is not news (#72). One per object, beside its hold:
21
+ * a note per helper had each of two helpers handed one object take the
22
+ * other's token as news, and ask again for every refusal.
23
+ */
24
+ const broughtBy = new WeakMap();
18
25
  /**
19
26
  * Send with the token in hand, and after a 401 once more with a different one
20
27
  * from `refreshToken`, all within the call's signal and deadline (#62). A 403
@@ -49,8 +56,11 @@ export async function sendWithTokenRefresh(source, noTokenAdvice, call, send) {
49
56
  *
50
57
  * What it learns is held as the fetch helpers hold it, and shared with them
51
58
  * when they are handed the same `tokens` object (#38): a token `refreshToken`
52
- * could not replace, and a failure that says when to ask again, are not asked
53
- * about again until they lapse or the token in hand changes.
59
+ * could not replace, a token it brought that the API refused on a tile
60
+ * reloaded with it, and a failure that says when to ask again, are not asked
61
+ * about again until they lapse or the token in hand changes. While the token
62
+ * a refresh brought is in hand, any other refused tile is reloaded once with
63
+ * it, without asking.
54
64
  *
55
65
  * Returns a function that stops listening.
56
66
  *
@@ -66,16 +76,26 @@ export async function sendWithTokenRefresh(source, noTokenAdvice, call, send) {
66
76
  export function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
67
77
  const hold = holdOf(tokens);
68
78
  // Each source's refused tiles, keyed `z/x/y` so a tile refused twice is
69
- // reloaded once.
79
+ // reloaded once. Tiles refused while a hold stands are kept, so the next ask
80
+ // reloads them; MapLibre ignores ids no longer in view.
70
81
  const refused = new Map();
82
+ // The token each tile was reloaded with here, keyed `source:z/x/y`.
83
+ const reloadedWith = new Map();
71
84
  let asking = false;
85
+ const reload = (sourceId, tiles, token) => {
86
+ for (const { x, y, z } of tiles)
87
+ reloadedWith.set(`${sourceId}:${z}/${x}/${y}`, token);
88
+ map.refreshTiles(sourceId, tiles);
89
+ };
72
90
  const onError = ({ error, sourceId, tile }) => {
73
91
  if (error?.status !== 401 || !error.url || !isOurApi(error.url, apiUrl))
74
92
  return;
93
+ let refusedTile;
75
94
  if (sourceId && tile) {
76
95
  const { x, y, z } = tile.tileID.canonical;
77
96
  const tiles = refused.get(sourceId) ?? new Map();
78
97
  refused.set(sourceId, tiles.set(`${z}/${x}/${y}`, { x, y, z }));
98
+ refusedTile = [sourceId, { x, y, z }];
79
99
  }
80
100
  if (asking)
81
101
  return;
@@ -83,6 +103,32 @@ export function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
83
103
  const held = hold.check(inHand);
84
104
  if (held && !held.askAgain)
85
105
  return;
106
+ // A 401 while the token a refresh brought is in hand. The event does not
107
+ // say which token the refused request carried, and a tile requested with
108
+ // the token before, answered late, looks like the new token refused. So
109
+ // only a tile reloaded here with the token in hand is that token refused:
110
+ // the API refuses every token, as it does a map pointed at an API its
111
+ // tokens are not for, and asking again minted a token and reloaded the
112
+ // tiles several times a second. That is remembered, as a send path
113
+ // remembers its retry refused, and the next ask is after the hold. Any
114
+ // other tile is reloaded once with the token in hand; anything else (a
115
+ // glyph, a sprite) asks nothing.
116
+ const brought = broughtBy.get(tokens);
117
+ if (inHand && inHand === brought?.token && Date.now() < brought.until) {
118
+ if (!refusedTile)
119
+ return;
120
+ const [id, coordinates] = refusedTile;
121
+ const key = `${coordinates.z}/${coordinates.x}/${coordinates.y}`;
122
+ if (reloadedWith.get(`${id}:${key}`) !== inHand) {
123
+ refused.get(id)?.delete(key);
124
+ reload(id, [coordinates], inHand);
125
+ return;
126
+ }
127
+ // Kept in `refused`, as every tile refused during the hold is.
128
+ broughtBy.delete(tokens);
129
+ hold.remember(unauthorized('The API refused a tile reloaded with the token refreshToken brought.'), inHand);
130
+ return;
131
+ }
86
132
  asking = true;
87
133
  // Asked inside an executor, so a `refreshToken` that throws instead of
88
134
  // rejecting lands in the catch below rather than in MapLibre's emitter.
@@ -96,15 +142,18 @@ export function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
96
142
  if (!now || now === inHand) {
97
143
  // MapLibre's error carries none of the API's fields, so the refusal
98
144
  // is remembered as the 401 it was.
99
- hold.remember(new LocationServiceException({
100
- code: 'UnauthorizedException',
101
- message: 'The API refused the token, and getToken has no other since refreshToken settled.',
102
- statusCode: 401,
103
- }), inHand);
145
+ hold.remember(unauthorized('The API refused the token, and getToken has no other since refreshToken settled.'), inHand);
104
146
  return;
105
147
  }
148
+ broughtBy.set(tokens, {
149
+ token: now,
150
+ until: Date.now() + TOKEN_REFUSAL_HOLD_MS,
151
+ });
152
+ // What was reloaded with an earlier token can no longer match.
153
+ reloadedWith.clear();
106
154
  for (const [id, tiles] of refused)
107
- map.refreshTiles(id, [...tiles.values()]);
155
+ if (tiles.size)
156
+ reload(id, [...tiles.values()], now);
108
157
  })
109
158
  .catch((refusal) => {
110
159
  // Only a failure that says when to ask again is remembered; any other
@@ -120,3 +169,9 @@ export function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
120
169
  map.on('error', onError);
121
170
  return () => map.off('error', onError);
122
171
  }
172
+ /** A refusal MapLibre reported, remembered as the 401 it was. */
173
+ const unauthorized = (message) => new LocationServiceException({
174
+ code: 'UnauthorizedException',
175
+ message,
176
+ statusCode: 401,
177
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",