@nodora/client 0.0.2 → 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/README.md CHANGED
@@ -24,7 +24,7 @@ const result = await checkout.evaluate("IsDiscountEligible", {
24
24
  });
25
25
  ```
26
26
 
27
- For configuration options, caching strategies, and detailed usage, see the official [documentation](https://docs.nodora.org).
27
+ For detailed information, see the official [documentation](https://nodora.org/docs).
28
28
 
29
29
  ## License
30
30
 
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
 
@@ -130,6 +167,38 @@ export declare class RulesetHandle {
130
167
  rule: string,
131
168
  input?: Record<string, any>,
132
169
  ): Promise<EvaluationResult>;
170
+
171
+ /**
172
+ * Registers a handler to be invoked when the given signal is emitted
173
+ * during an `evaluate()` call on this handle.
174
+ *
175
+ * Multiple handlers can be registered for the same signal; all are
176
+ * called in registration order. Returns the handle for chaining.
177
+ *
178
+ * @param signal - The name of the signal to listen for.
179
+ * @param handler - Called with the signal's arguments when emitted.
180
+ *
181
+ * @example
182
+ * client.ruleset("Checkout")
183
+ * .on("discount_applied", (amount) => log(amount))
184
+ * .on("fraud_flagged", (reason) => alert(reason));
185
+ */
186
+ on(
187
+ signal: string,
188
+ handler: (...args: any[]) => void,
189
+ ): this;
190
+
191
+ /**
192
+ * Removes a previously registered handler for the given signal.
193
+ * The handler reference must match the one passed to `on()`.
194
+ *
195
+ * @param signal - The name of the signal.
196
+ * @param handler - The handler to remove.
197
+ */
198
+ off(
199
+ signal: string,
200
+ handler: (...args: any[]) => void,
201
+ ): this;
133
202
  }
134
203
 
135
204
  /**
@@ -163,6 +232,20 @@ export declare class NodoraClient {
163
232
  */
164
233
  prefetch(): Promise<void>;
165
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
+
166
249
  /**
167
250
  * Returns a handle for a specific ruleset.
168
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
  }
@@ -46,11 +57,13 @@ export class NodoraClient {
46
57
  }
47
58
 
48
59
  _evaluate(rulesetName, ruleName, input) {
49
- if (this._abortController.signal.aborted) return;
60
+ if (this._abortController.signal.aborted) {
61
+ throw new Error("client is destroyed");
62
+ }
50
63
 
51
64
  const entry = this._cache.get(rulesetName);
52
65
  if (!entry) {
53
- throw new Error(`Ruleset "${rulesetName}" not found`);
66
+ throw new Error(`ruleset "${rulesetName}" not found`);
54
67
  }
55
68
 
56
69
  let result;
@@ -89,7 +102,7 @@ export class NodoraClient {
89
102
  Date.now() - this._lastFetchedAt > strategy.ttl
90
103
  ) {
91
104
  this._refreshGate(() => this._refresh()).catch((err) =>
92
- this._onError(err)
105
+ this._onError(err),
93
106
  );
94
107
  }
95
108
 
@@ -104,7 +117,7 @@ export class NodoraClient {
104
117
  }
105
118
 
106
119
  async _init() {
107
- await this._refresh();
120
+ await this._refreshGate(() => this._refresh());
108
121
 
109
122
  if (this._flushTimer) return;
110
123
  this._flushTimer = setInterval(() => {
@@ -115,6 +128,20 @@ export class NodoraClient {
115
128
  }
116
129
  this._flushEvents();
117
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
+ }
118
145
  }
119
146
 
120
147
  async _fetch() {
@@ -128,13 +155,39 @@ export class NodoraClient {
128
155
  ]),
129
156
  });
130
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
+
131
168
  if (!res.ok) {
132
- throw new Error(`Failed to fetch releases: HTTP ${res.status}`);
169
+ throw new Error(`failed to fetch releases: HTTP ${res.status}`);
133
170
  }
134
171
 
135
172
  return res.json();
136
173
  }
137
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
+
138
191
  async _syncEvaluators(data) {
139
192
  const entries = Object.entries(data);
140
193
  await Promise.all(
@@ -145,17 +198,18 @@ export class NodoraClient {
145
198
  return;
146
199
  }
147
200
 
148
- if (existing && existing.evaluator) {
149
- existing.evaluator.destroy();
150
- }
151
-
152
201
  const evaluator = await createEvaluator(release.compiled);
153
202
  this._cache.set(rulesetName, {
154
203
  rulesetId: release.rulesetId,
155
204
  checksum: release.checksum,
156
205
  evaluator,
157
206
  });
158
- })
207
+
208
+ // clean up old evaluator
209
+ if (existing && existing.evaluator) {
210
+ existing.evaluator.destroy();
211
+ }
212
+ }),
159
213
  );
160
214
 
161
215
  // clean up deleted rulesets
@@ -169,9 +223,13 @@ export class NodoraClient {
169
223
  }
170
224
 
171
225
  async _refresh() {
226
+ // honor a 429 backoff window
227
+ if (Date.now() < this._retryAfterUntil) return;
228
+
172
229
  try {
173
230
  const data = await this._fetch();
174
231
  this._lastFetchedAt = Date.now();
232
+ this._retryAfterUntil = 0;
175
233
  await this._syncEvaluators(data);
176
234
  } catch (err) {
177
235
  if (this._abortController.signal.aborted) return;
@@ -183,9 +241,12 @@ export class NodoraClient {
183
241
  for (const event of this._metrics.drain()) {
184
242
  this._eventBuffer.push(event);
185
243
  }
186
- 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
+ }
187
248
 
188
- return this._flushGate(async () => {
249
+ const flush = this._flushGate(async () => {
189
250
  const batch = this._eventBuffer.splice(0);
190
251
  const { baseUrl, apiKey, timeout } = this._options;
191
252
 
@@ -203,6 +264,9 @@ export class NodoraClient {
203
264
  ]),
204
265
  });
205
266
  } catch (err) {
267
+ // the request was aborted by destroy()
268
+ if (this._abortController.signal.aborted) return;
269
+
206
270
  this._eventBuffer.unshift(...batch);
207
271
  const max = this._options.maxBufferSize;
208
272
  if (this._eventBuffer.length > max) {
@@ -211,6 +275,12 @@ export class NodoraClient {
211
275
  this._onError(err);
212
276
  }
213
277
  });
278
+
279
+ this._pendingFlush = flush;
280
+ flush.finally(() => {
281
+ if (this._pendingFlush === flush) this._pendingFlush = null;
282
+ });
283
+ return flush;
214
284
  }
215
285
 
216
286
  async destroy() {
@@ -221,6 +291,11 @@ export class NodoraClient {
221
291
  this._flushTimer = null;
222
292
  }
223
293
 
294
+ if (this._pollTimer) {
295
+ clearInterval(this._pollTimer);
296
+ this._pollTimer = null;
297
+ }
298
+
224
299
  this._eventBuffer.length = 0;
225
300
  this._metrics.clear();
226
301
 
@@ -238,10 +313,45 @@ class RulesetHandle {
238
313
  constructor(client, ruleset) {
239
314
  this._client = client;
240
315
  this._ruleset = ruleset;
316
+ this._signals = new Map();
241
317
  }
242
318
 
243
319
  async evaluate(rule, input = {}) {
244
320
  await this._client.prefetch();
245
- return this._client._evaluate(this._ruleset, rule, input);
321
+ const result = this._client._evaluate(this._ruleset, rule, input);
322
+
323
+ if (result.emitted_signals && result.emitted_signals.length > 0) {
324
+ for (const signal of result.emitted_signals) {
325
+ const handlers = this._signals.get(signal.name);
326
+ if (handlers) {
327
+ for (const handler of handlers) {
328
+ try {
329
+ handler(...signal.args);
330
+ } catch (err) {
331
+ this._client._onError(err);
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+ return result;
338
+ }
339
+
340
+ on(signal, handler) {
341
+ let handlers = this._signals.get(signal);
342
+ if (!handlers) {
343
+ handlers = new Set();
344
+ this._signals.set(signal, handlers);
345
+ }
346
+ handlers.add(handler);
347
+ return this;
348
+ }
349
+
350
+ off(signal, handler) {
351
+ const handlers = this._signals.get(signal);
352
+ if (!handlers) return this;
353
+ handlers.delete(handler);
354
+ if (handlers.size === 0) this._signals.delete(signal);
355
+ return this;
246
356
  }
247
357
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodora/client",
3
- "version": "0.0.2",
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",