@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 +1 -1
- package/lib/client.d.ts +101 -18
- package/lib/client.js +123 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ const result = await checkout.evaluate("IsDiscountEligible", {
|
|
|
24
24
|
});
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
For
|
|
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
|
-
*
|
|
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
|
|
|
@@ -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)
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|