@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 CHANGED
@@ -10,24 +10,56 @@ export type Environment = "staging" | "production";
10
10
  /**
11
11
  * Caching strategy configuration.
12
12
  *
13
- * Currently supports a stale-while-revalidate approach:
14
- * - Returns cached data immediately (even if stale)
15
- * - Fetches fresh data in the background and updates cache
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
- * Strategy type.
20
- */
21
- type: "stale-while-revalidate";
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
- * Time-to-live for cached data (in milliseconds).
25
- * After this duration, cached data is considered stale and will revalidate.
26
- *
27
- * @example 60000 // 1 minute
28
- */
29
- ttl: number;
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
- * If omitted, caching is disabled.
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
- * @example { type: "stale-while-revalidate", ttl: 30000 }
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
- if (this._eventBuffer.length === 0) return Promise.resolve();
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
- return this._flushGate(async () => {
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodora/client",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Nodora platform client for ruleset evaluation",
5
5
  "main": "lib/client.js",
6
6
  "types": "lib/client.d.ts",