@nodora/client 0.0.3 → 0.0.4
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/lib/client.d.ts +69 -18
- package/lib/client.js +82 -9
- package/package.json +1 -1
package/lib/client.d.ts
CHANGED
|
@@ -10,24 +10,56 @@ export type Environment = "staging" | "production";
|
|
|
10
10
|
/**
|
|
11
11
|
* Caching strategy configuration.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
13
|
+
* - `stale-while-revalidate`: returns cached data immediately (even if stale)
|
|
14
|
+
* and re-fetches in the background once the TTL has elapsed. Revalidation is
|
|
15
|
+
* lazy: it is only triggered by an evaluation. This is the default strategy
|
|
16
|
+
* and a good general-purpose choice.
|
|
17
|
+
* - `poll`: re-fetches on a fixed wall-clock interval regardless of evaluation
|
|
18
|
+
* activity. Bounds staleness to roughly `interval` even when the ruleset is
|
|
19
|
+
* evaluated rarely, at the cost of background traffic while the client is
|
|
20
|
+
* alive. Prefer this for low-frequency evaluations where a stale decision is
|
|
21
|
+
* costly.
|
|
22
|
+
* - `immutable`: fetches the ruleset once and caches it for the lifetime of
|
|
23
|
+
* the client. It is never revalidated, so a rule released after the first
|
|
24
|
+
* fetch will not be picked up until the client is recreated (or you call
|
|
25
|
+
* `refresh()` manually).
|
|
16
26
|
*/
|
|
17
|
-
export type Strategy =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
export type Strategy =
|
|
28
|
+
| {
|
|
29
|
+
/**
|
|
30
|
+
* Serve cached data immediately and revalidate in the background.
|
|
31
|
+
*/
|
|
32
|
+
type: "stale-while-revalidate";
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Time-to-live for cached data (in milliseconds).
|
|
36
|
+
* After this duration, cached data is considered stale and the next
|
|
37
|
+
* evaluation triggers a background revalidation.
|
|
38
|
+
*
|
|
39
|
+
* @example 60000 // 1 minute
|
|
40
|
+
*/
|
|
41
|
+
ttl: number;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
/**
|
|
45
|
+
* Revalidate on a fixed interval, independent of evaluation activity.
|
|
46
|
+
*/
|
|
47
|
+
type: "poll";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Interval between background re-fetches (in milliseconds). Polling
|
|
51
|
+
* starts on the first `prefetch()`/`evaluate()` and stops on `destroy()`.
|
|
52
|
+
*
|
|
53
|
+
* @example 60000 // 1 minute
|
|
54
|
+
*/
|
|
55
|
+
interval: number;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
/**
|
|
59
|
+
* Fetch once and cache indefinitely; never revalidate automatically.
|
|
60
|
+
*/
|
|
61
|
+
type: "immutable";
|
|
62
|
+
};
|
|
31
63
|
|
|
32
64
|
export interface ClientOptions {
|
|
33
65
|
/**
|
|
@@ -55,9 +87,14 @@ export interface ClientOptions {
|
|
|
55
87
|
/**
|
|
56
88
|
* Optional caching strategy.
|
|
57
89
|
*
|
|
58
|
-
*
|
|
90
|
+
* Defaults to `{ type: "stale-while-revalidate", ttl: 60000 }` so released
|
|
91
|
+
* rule edits reach long-running clients within the TTL without a restart.
|
|
92
|
+
* Pass `{ type: "poll", interval }` to revalidate on a fixed interval even
|
|
93
|
+
* when evaluations are infrequent, or `{ type: "immutable" }` to fetch once
|
|
94
|
+
* and cache indefinitely.
|
|
59
95
|
*
|
|
60
|
-
* @
|
|
96
|
+
* @default { type: "stale-while-revalidate", ttl: 60000 }
|
|
97
|
+
* @example { type: "poll", interval: 30000 }
|
|
61
98
|
*/
|
|
62
99
|
strategy?: Strategy;
|
|
63
100
|
|
|
@@ -195,6 +232,20 @@ export declare class NodoraClient {
|
|
|
195
232
|
*/
|
|
196
233
|
prefetch(): Promise<void>;
|
|
197
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Forces an immediate revalidation of all cached rulesets, replacing them
|
|
237
|
+
* with the currently released versions.
|
|
238
|
+
*
|
|
239
|
+
* Useful with `{ type: "immutable" }`, which never revalidates on its own,
|
|
240
|
+
* or to pick up a release on demand instead of waiting for the TTL.
|
|
241
|
+
*
|
|
242
|
+
* @returns A promise that resolves once the refresh completes.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* await client.refresh();
|
|
246
|
+
*/
|
|
247
|
+
refresh(): Promise<void>;
|
|
248
|
+
|
|
198
249
|
/**
|
|
199
250
|
* Returns a handle for a specific ruleset.
|
|
200
251
|
*
|
package/lib/client.js
CHANGED
|
@@ -2,6 +2,9 @@ import { createEvaluator } from "@nodora/js";
|
|
|
2
2
|
import { MetricAccumulator } from "./metrics";
|
|
3
3
|
import { asyncGate } from "./utils";
|
|
4
4
|
|
|
5
|
+
const DEFAULT_RETRY_AFTER_MS = 60000;
|
|
6
|
+
const MAX_RETRY_AFTER_MS = 3600000;
|
|
7
|
+
|
|
5
8
|
export class NodoraClient {
|
|
6
9
|
constructor(options) {
|
|
7
10
|
if (!options || !options.apiKey) {
|
|
@@ -15,14 +18,18 @@ export class NodoraClient {
|
|
|
15
18
|
flushInterval: 10000,
|
|
16
19
|
maxBufferSize: 300,
|
|
17
20
|
timeout: 10000,
|
|
21
|
+
strategy: { type: "stale-while-revalidate", ttl: 60000 },
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
this._options = { ...defaultOptions, ...options };
|
|
21
25
|
this._onError = options.onError || (() => {});
|
|
22
26
|
this._cache = new Map();
|
|
23
27
|
this._lastFetchedAt = 0;
|
|
28
|
+
this._retryAfterUntil = 0;
|
|
24
29
|
this._flushTimer = null;
|
|
30
|
+
this._pollTimer = null;
|
|
25
31
|
this._eventBuffer = [];
|
|
32
|
+
this._pendingFlush = null;
|
|
26
33
|
this._initPromise = null;
|
|
27
34
|
this._refreshGate = asyncGate();
|
|
28
35
|
this._flushGate = asyncGate();
|
|
@@ -37,6 +44,10 @@ export class NodoraClient {
|
|
|
37
44
|
return this._initPromise;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
async refresh() {
|
|
48
|
+
return this._refreshGate(() => this._refresh());
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
async flush() {
|
|
41
52
|
return this._flushEvents();
|
|
42
53
|
}
|
|
@@ -91,7 +102,7 @@ export class NodoraClient {
|
|
|
91
102
|
Date.now() - this._lastFetchedAt > strategy.ttl
|
|
92
103
|
) {
|
|
93
104
|
this._refreshGate(() => this._refresh()).catch((err) =>
|
|
94
|
-
this._onError(err)
|
|
105
|
+
this._onError(err),
|
|
95
106
|
);
|
|
96
107
|
}
|
|
97
108
|
|
|
@@ -106,7 +117,7 @@ export class NodoraClient {
|
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
async _init() {
|
|
109
|
-
await this._refresh();
|
|
120
|
+
await this._refreshGate(() => this._refresh());
|
|
110
121
|
|
|
111
122
|
if (this._flushTimer) return;
|
|
112
123
|
this._flushTimer = setInterval(() => {
|
|
@@ -117,6 +128,20 @@ export class NodoraClient {
|
|
|
117
128
|
}
|
|
118
129
|
this._flushEvents();
|
|
119
130
|
}, this._options.flushInterval);
|
|
131
|
+
|
|
132
|
+
const strategy = this._options.strategy;
|
|
133
|
+
if (strategy && strategy.type === "poll" && !this._pollTimer) {
|
|
134
|
+
this._pollTimer = setInterval(() => {
|
|
135
|
+
if (this._abortController.signal.aborted) {
|
|
136
|
+
clearInterval(this._pollTimer);
|
|
137
|
+
this._pollTimer = null;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this._refreshGate(() => this._refresh()).catch((err) =>
|
|
141
|
+
this._onError(err),
|
|
142
|
+
);
|
|
143
|
+
}, strategy.interval);
|
|
144
|
+
}
|
|
120
145
|
}
|
|
121
146
|
|
|
122
147
|
async _fetch() {
|
|
@@ -130,6 +155,16 @@ export class NodoraClient {
|
|
|
130
155
|
]),
|
|
131
156
|
});
|
|
132
157
|
|
|
158
|
+
if (res.status === 429) {
|
|
159
|
+
const delayMs = this._parseRetryAfter(res.headers.get("retry-after"));
|
|
160
|
+
this._retryAfterUntil = Date.now() + delayMs;
|
|
161
|
+
throw new Error(
|
|
162
|
+
`rate limited fetching releases, backing off for ${Math.ceil(
|
|
163
|
+
delayMs / 1000,
|
|
164
|
+
)}s`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
133
168
|
if (!res.ok) {
|
|
134
169
|
throw new Error(`failed to fetch releases: HTTP ${res.status}`);
|
|
135
170
|
}
|
|
@@ -137,6 +172,22 @@ export class NodoraClient {
|
|
|
137
172
|
return res.json();
|
|
138
173
|
}
|
|
139
174
|
|
|
175
|
+
_parseRetryAfter(headerValue) {
|
|
176
|
+
if (!headerValue) return DEFAULT_RETRY_AFTER_MS;
|
|
177
|
+
|
|
178
|
+
const seconds = Number(headerValue); // Retry-After: <delay-seconds>
|
|
179
|
+
if (Number.isFinite(seconds)) {
|
|
180
|
+
return Math.min(Math.max(seconds * 1000, 0), MAX_RETRY_AFTER_MS);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const date = Date.parse(headerValue); // Retry-After: <HTTP-date>
|
|
184
|
+
if (!Number.isNaN(date)) {
|
|
185
|
+
return Math.min(Math.max(date - Date.now(), 0), MAX_RETRY_AFTER_MS);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return DEFAULT_RETRY_AFTER_MS;
|
|
189
|
+
}
|
|
190
|
+
|
|
140
191
|
async _syncEvaluators(data) {
|
|
141
192
|
const entries = Object.entries(data);
|
|
142
193
|
await Promise.all(
|
|
@@ -147,17 +198,18 @@ export class NodoraClient {
|
|
|
147
198
|
return;
|
|
148
199
|
}
|
|
149
200
|
|
|
150
|
-
if (existing && existing.evaluator) {
|
|
151
|
-
existing.evaluator.destroy();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
201
|
const evaluator = await createEvaluator(release.compiled);
|
|
155
202
|
this._cache.set(rulesetName, {
|
|
156
203
|
rulesetId: release.rulesetId,
|
|
157
204
|
checksum: release.checksum,
|
|
158
205
|
evaluator,
|
|
159
206
|
});
|
|
160
|
-
|
|
207
|
+
|
|
208
|
+
// clean up old evaluator
|
|
209
|
+
if (existing && existing.evaluator) {
|
|
210
|
+
existing.evaluator.destroy();
|
|
211
|
+
}
|
|
212
|
+
}),
|
|
161
213
|
);
|
|
162
214
|
|
|
163
215
|
// clean up deleted rulesets
|
|
@@ -171,9 +223,13 @@ export class NodoraClient {
|
|
|
171
223
|
}
|
|
172
224
|
|
|
173
225
|
async _refresh() {
|
|
226
|
+
// honor a 429 backoff window
|
|
227
|
+
if (Date.now() < this._retryAfterUntil) return;
|
|
228
|
+
|
|
174
229
|
try {
|
|
175
230
|
const data = await this._fetch();
|
|
176
231
|
this._lastFetchedAt = Date.now();
|
|
232
|
+
this._retryAfterUntil = 0;
|
|
177
233
|
await this._syncEvaluators(data);
|
|
178
234
|
} catch (err) {
|
|
179
235
|
if (this._abortController.signal.aborted) return;
|
|
@@ -185,9 +241,12 @@ export class NodoraClient {
|
|
|
185
241
|
for (const event of this._metrics.drain()) {
|
|
186
242
|
this._eventBuffer.push(event);
|
|
187
243
|
}
|
|
188
|
-
|
|
244
|
+
// nothing buffered: still await an in-flight flush
|
|
245
|
+
if (this._eventBuffer.length === 0) {
|
|
246
|
+
return this._pendingFlush ?? Promise.resolve();
|
|
247
|
+
}
|
|
189
248
|
|
|
190
|
-
|
|
249
|
+
const flush = this._flushGate(async () => {
|
|
191
250
|
const batch = this._eventBuffer.splice(0);
|
|
192
251
|
const { baseUrl, apiKey, timeout } = this._options;
|
|
193
252
|
|
|
@@ -205,6 +264,9 @@ export class NodoraClient {
|
|
|
205
264
|
]),
|
|
206
265
|
});
|
|
207
266
|
} catch (err) {
|
|
267
|
+
// the request was aborted by destroy()
|
|
268
|
+
if (this._abortController.signal.aborted) return;
|
|
269
|
+
|
|
208
270
|
this._eventBuffer.unshift(...batch);
|
|
209
271
|
const max = this._options.maxBufferSize;
|
|
210
272
|
if (this._eventBuffer.length > max) {
|
|
@@ -213,6 +275,12 @@ export class NodoraClient {
|
|
|
213
275
|
this._onError(err);
|
|
214
276
|
}
|
|
215
277
|
});
|
|
278
|
+
|
|
279
|
+
this._pendingFlush = flush;
|
|
280
|
+
flush.finally(() => {
|
|
281
|
+
if (this._pendingFlush === flush) this._pendingFlush = null;
|
|
282
|
+
});
|
|
283
|
+
return flush;
|
|
216
284
|
}
|
|
217
285
|
|
|
218
286
|
async destroy() {
|
|
@@ -223,6 +291,11 @@ export class NodoraClient {
|
|
|
223
291
|
this._flushTimer = null;
|
|
224
292
|
}
|
|
225
293
|
|
|
294
|
+
if (this._pollTimer) {
|
|
295
|
+
clearInterval(this._pollTimer);
|
|
296
|
+
this._pollTimer = null;
|
|
297
|
+
}
|
|
298
|
+
|
|
226
299
|
this._eventBuffer.length = 0;
|
|
227
300
|
this._metrics.clear();
|
|
228
301
|
|