@traffical/node 0.1.2
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 +154 -0
- package/dist/client.d.ts +200 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +419 -0
- package/dist/client.js.map +1 -0
- package/dist/event-batcher.d.ts +74 -0
- package/dist/event-batcher.d.ts.map +1 -0
- package/dist/event-batcher.js +165 -0
- package/dist/event-batcher.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/client.ts +564 -0
- package/src/event-batcher.ts +210 -0
- package/src/index.ts +17 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @traffical/node
|
|
3
|
+
*
|
|
4
|
+
* Traffical SDK for Node.js environments.
|
|
5
|
+
* Provides HTTP client with caching, background refresh, and event tracking.
|
|
6
|
+
*/
|
|
7
|
+
export * from "@traffical/core";
|
|
8
|
+
export { TrafficalClient, createTrafficalClient, createTrafficalClientSync, } from "./client.js";
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,iBAAiB,CAAC;AAGhC,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,yBAAyB,GAC1B,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @traffical/node
|
|
3
|
+
*
|
|
4
|
+
* Traffical SDK for Node.js environments.
|
|
5
|
+
* Provides HTTP client with caching, background refresh, and event tracking.
|
|
6
|
+
*/
|
|
7
|
+
// Re-export everything from core
|
|
8
|
+
export * from "@traffical/core";
|
|
9
|
+
// Export Node-specific client
|
|
10
|
+
export { TrafficalClient, createTrafficalClient, createTrafficalClientSync, } from "./client.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,iCAAiC;AACjC,cAAc,iBAAiB,CAAC;AAEhC,8BAA8B;AAC9B,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,yBAAyB,GAC1B,MAAM,aAAa,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@traffical/node",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Traffical SDK for Node.js - HTTP client with caching and event tracking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"module": "./src/index.ts",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"bun": "./src/index.ts",
|
|
13
|
+
"import": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"dev": "tsc --watch",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"release": "bun scripts/release.ts"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@traffical/core": "workspace:^"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/bun": "latest",
|
|
32
|
+
"typescript": "^5.3.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"traffical",
|
|
36
|
+
"feature-flags",
|
|
37
|
+
"experimentation",
|
|
38
|
+
"a/b-testing",
|
|
39
|
+
"node"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/traffical/js-sdk"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traffical Node.js SDK Client
|
|
3
|
+
*
|
|
4
|
+
* HTTP client with caching, background refresh, and graceful degradation.
|
|
5
|
+
* Wraps the pure core-ts resolution engine.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - ETag-based caching for efficient config fetches
|
|
9
|
+
* - Background refresh for keeping config up-to-date
|
|
10
|
+
* - Automatic decision tracking for intent-to-treat analysis
|
|
11
|
+
* - Batched event transport for efficiency
|
|
12
|
+
* - Graceful degradation with local config and schema defaults
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type ConfigBundle,
|
|
17
|
+
type Context,
|
|
18
|
+
type DecisionResult,
|
|
19
|
+
type ParameterValue,
|
|
20
|
+
type TrafficalClientOptions as CoreClientOptions,
|
|
21
|
+
type TrackOptions,
|
|
22
|
+
type ExposureEvent,
|
|
23
|
+
type TrackEvent,
|
|
24
|
+
type TrackAttribution,
|
|
25
|
+
type DecisionEvent,
|
|
26
|
+
resolveParameters,
|
|
27
|
+
decide as coreDecide,
|
|
28
|
+
DecisionDeduplicator,
|
|
29
|
+
generateExposureId,
|
|
30
|
+
generateTrackEventId,
|
|
31
|
+
} from "@traffical/core";
|
|
32
|
+
|
|
33
|
+
import { EventBatcher } from "./event-batcher.js";
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Constants
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
const SDK_NAME = "node";
|
|
40
|
+
const SDK_VERSION = "0.1.0"; // Should match package.json version
|
|
41
|
+
|
|
42
|
+
const DEFAULT_BASE_URL = "https://sdk.traffical.io";
|
|
43
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 60_000; // 1 minute
|
|
44
|
+
const OFFLINE_WARNING_INTERVAL_MS = 300_000; // 5 minutes
|
|
45
|
+
const DECISION_CACHE_MAX_SIZE = 1000; // Max decisions to cache for attribution lookup
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Types
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Options for the Node.js Traffical client.
|
|
53
|
+
* Extends the core options with Node-specific settings.
|
|
54
|
+
*/
|
|
55
|
+
export interface TrafficalClientOptions extends CoreClientOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Whether to automatically track decision events (default: true).
|
|
58
|
+
* When enabled, every call to decide() automatically sends a DecisionEvent
|
|
59
|
+
* to the backend, enabling intent-to-treat analysis.
|
|
60
|
+
*/
|
|
61
|
+
trackDecisions?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Decision deduplication TTL in milliseconds (default: 1 hour).
|
|
64
|
+
* Same user+assignment combination won't be tracked again within this window.
|
|
65
|
+
*/
|
|
66
|
+
decisionDeduplicationTtlMs?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Event batch size - number of events before auto-flush (default: 10).
|
|
69
|
+
*/
|
|
70
|
+
eventBatchSize?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Event flush interval in milliseconds (default: 30000).
|
|
73
|
+
*/
|
|
74
|
+
eventFlushIntervalMs?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Enable debug logging for events (default: false).
|
|
77
|
+
*/
|
|
78
|
+
debugEvents?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// Client State
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
interface ClientState {
|
|
86
|
+
bundle: ConfigBundle | null;
|
|
87
|
+
etag: string | null;
|
|
88
|
+
lastFetchTime: number;
|
|
89
|
+
lastOfflineWarning: number;
|
|
90
|
+
refreshTimer: ReturnType<typeof setInterval> | null;
|
|
91
|
+
isInitialized: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// Traffical Client Class
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* TrafficalClient - the main SDK client for Node.js environments.
|
|
100
|
+
*
|
|
101
|
+
* Features:
|
|
102
|
+
* - ETag-based caching for efficient config fetches
|
|
103
|
+
* - Background refresh for keeping config up-to-date
|
|
104
|
+
* - Automatic decision tracking for intent-to-treat analysis
|
|
105
|
+
* - Batched event transport for efficiency
|
|
106
|
+
* - Graceful degradation with local config and schema defaults
|
|
107
|
+
* - Rate-limited offline warnings
|
|
108
|
+
*/
|
|
109
|
+
export class TrafficalClient {
|
|
110
|
+
private readonly _options: Required<
|
|
111
|
+
Pick<
|
|
112
|
+
TrafficalClientOptions,
|
|
113
|
+
"orgId" | "projectId" | "env" | "apiKey" | "baseUrl" | "refreshIntervalMs" | "strictMode"
|
|
114
|
+
>
|
|
115
|
+
> & {
|
|
116
|
+
localConfig?: ConfigBundle;
|
|
117
|
+
trackDecisions: boolean;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
private _state: ClientState = {
|
|
121
|
+
bundle: null,
|
|
122
|
+
etag: null,
|
|
123
|
+
lastFetchTime: 0,
|
|
124
|
+
lastOfflineWarning: 0,
|
|
125
|
+
refreshTimer: null,
|
|
126
|
+
isInitialized: false,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
private readonly _eventBatcher: EventBatcher;
|
|
130
|
+
private readonly _decisionDedup: DecisionDeduplicator;
|
|
131
|
+
/** Cache of recent decisions for attribution lookup on rewards */
|
|
132
|
+
private readonly _decisionCache: Map<string, DecisionResult> = new Map();
|
|
133
|
+
|
|
134
|
+
constructor(options: TrafficalClientOptions) {
|
|
135
|
+
this._options = {
|
|
136
|
+
orgId: options.orgId,
|
|
137
|
+
projectId: options.projectId,
|
|
138
|
+
env: options.env,
|
|
139
|
+
apiKey: options.apiKey,
|
|
140
|
+
baseUrl: options.baseUrl || DEFAULT_BASE_URL,
|
|
141
|
+
localConfig: options.localConfig,
|
|
142
|
+
refreshIntervalMs: options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS,
|
|
143
|
+
strictMode: options.strictMode ?? false,
|
|
144
|
+
trackDecisions: options.trackDecisions !== false, // Default: true
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Initialize event batcher
|
|
148
|
+
this._eventBatcher = new EventBatcher({
|
|
149
|
+
endpoint: `${this._options.baseUrl}/v1/events/batch`,
|
|
150
|
+
apiKey: options.apiKey,
|
|
151
|
+
batchSize: options.eventBatchSize,
|
|
152
|
+
flushIntervalMs: options.eventFlushIntervalMs,
|
|
153
|
+
debug: options.debugEvents,
|
|
154
|
+
onError: (error) => {
|
|
155
|
+
console.warn(`[Traffical] Event batching error: ${error.message}`);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Initialize decision deduplicator
|
|
160
|
+
this._decisionDedup = new DecisionDeduplicator({
|
|
161
|
+
ttlMs: options.decisionDeduplicationTtlMs,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Initialize with local config if provided
|
|
165
|
+
if (this._options.localConfig) {
|
|
166
|
+
this._state.bundle = this._options.localConfig;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Initializes the client by fetching the config bundle.
|
|
172
|
+
* This is called automatically by createTrafficalClient.
|
|
173
|
+
*/
|
|
174
|
+
async initialize(): Promise<void> {
|
|
175
|
+
await this._fetchConfig();
|
|
176
|
+
this._startBackgroundRefresh();
|
|
177
|
+
this._state.isInitialized = true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Stops background refresh and cleans up resources.
|
|
182
|
+
*/
|
|
183
|
+
async destroy(): Promise<void> {
|
|
184
|
+
if (this._state.refreshTimer) {
|
|
185
|
+
clearInterval(this._state.refreshTimer);
|
|
186
|
+
this._state.refreshTimer = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Flush remaining events
|
|
190
|
+
await this._eventBatcher.destroy();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Synchronous destroy for process exit handlers.
|
|
195
|
+
* Use destroy() when possible for proper cleanup.
|
|
196
|
+
*/
|
|
197
|
+
destroySync(): void {
|
|
198
|
+
if (this._state.refreshTimer) {
|
|
199
|
+
clearInterval(this._state.refreshTimer);
|
|
200
|
+
this._state.refreshTimer = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this._eventBatcher.destroySync();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Manually refreshes the config bundle.
|
|
208
|
+
*/
|
|
209
|
+
async refreshConfig(): Promise<void> {
|
|
210
|
+
await this._fetchConfig();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Gets the current config bundle version.
|
|
215
|
+
*/
|
|
216
|
+
getConfigVersion(): string | null {
|
|
217
|
+
return this._state.bundle?.version ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Flush pending events immediately.
|
|
222
|
+
*/
|
|
223
|
+
async flushEvents(): Promise<void> {
|
|
224
|
+
await this._eventBatcher.flush();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolves parameters with defaults as fallback.
|
|
229
|
+
*
|
|
230
|
+
* Resolution priority (highest wins):
|
|
231
|
+
* 1. Policy overrides (from remote bundle)
|
|
232
|
+
* 2. Parameter defaults (from remote bundle)
|
|
233
|
+
* 3. Local config (if remote unavailable)
|
|
234
|
+
* 4. Caller defaults
|
|
235
|
+
*/
|
|
236
|
+
getParams<T extends Record<string, ParameterValue>>(options: { context: Context; defaults: T }): T {
|
|
237
|
+
const bundle = this._getEffectiveBundle();
|
|
238
|
+
return resolveParameters<T>(bundle, options.context, options.defaults);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Makes a decision with full metadata for tracking.
|
|
243
|
+
*
|
|
244
|
+
* When trackDecisions is enabled (default), automatically sends a DecisionEvent
|
|
245
|
+
* to the backend for intent-to-treat analysis.
|
|
246
|
+
*/
|
|
247
|
+
decide<T extends Record<string, ParameterValue>>(options: { context: Context; defaults: T }): DecisionResult {
|
|
248
|
+
const start = Date.now();
|
|
249
|
+
const bundle = this._getEffectiveBundle();
|
|
250
|
+
const decision = coreDecide<T>(bundle, options.context, options.defaults);
|
|
251
|
+
const latencyMs = Date.now() - start;
|
|
252
|
+
|
|
253
|
+
// Cache decision for attribution lookup when trackReward is called
|
|
254
|
+
this._cacheDecision(decision);
|
|
255
|
+
|
|
256
|
+
// Auto-track decision if enabled
|
|
257
|
+
if (this._options.trackDecisions) {
|
|
258
|
+
this._trackDecision(decision, latencyMs, Object.keys(options.defaults));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return decision;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Tracks an exposure event.
|
|
266
|
+
*
|
|
267
|
+
* If the decision includes filtered context (from policies with contextLogging),
|
|
268
|
+
* it will be included in the exposure event for contextual bandit training.
|
|
269
|
+
*/
|
|
270
|
+
trackExposure(decision: DecisionResult): void {
|
|
271
|
+
const unitKey = decision.metadata.unitKeyValue;
|
|
272
|
+
if (!unitKey) {
|
|
273
|
+
// Can't track without unit key
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const event: ExposureEvent = {
|
|
278
|
+
type: "exposure",
|
|
279
|
+
id: generateExposureId(), // Unique exposure ID (not same as decision)
|
|
280
|
+
decisionId: decision.decisionId,
|
|
281
|
+
orgId: this._options.orgId,
|
|
282
|
+
projectId: this._options.projectId,
|
|
283
|
+
env: this._options.env,
|
|
284
|
+
unitKey,
|
|
285
|
+
timestamp: new Date().toISOString(),
|
|
286
|
+
assignments: decision.assignments,
|
|
287
|
+
layers: decision.metadata.layers,
|
|
288
|
+
// Include filtered context for contextual bandit training
|
|
289
|
+
context: decision.metadata.filteredContext,
|
|
290
|
+
sdkName: SDK_NAME,
|
|
291
|
+
sdkVersion: SDK_VERSION,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
this._eventBatcher.log(event);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Tracks a user event.
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* // Track a purchase with revenue
|
|
302
|
+
* client.track('purchase', { value: 99.99, orderId: 'ord_123' });
|
|
303
|
+
*
|
|
304
|
+
* // Track a simple event
|
|
305
|
+
* client.track('add_to_cart', { itemId: 'sku_456' });
|
|
306
|
+
*
|
|
307
|
+
* // Track with explicit decision attribution
|
|
308
|
+
* client.track('checkout_complete', { value: 1 }, { decisionId: 'dec_xyz' });
|
|
309
|
+
*/
|
|
310
|
+
track(
|
|
311
|
+
event: string,
|
|
312
|
+
properties?: Record<string, unknown>,
|
|
313
|
+
options?: { decisionId?: string; unitKey?: string }
|
|
314
|
+
): void {
|
|
315
|
+
const value = typeof properties?.value === 'number' ? properties.value : undefined;
|
|
316
|
+
|
|
317
|
+
// Auto-populate attribution from cached decision if available
|
|
318
|
+
const attribution = this._getAttributionFromCache(options?.decisionId);
|
|
319
|
+
|
|
320
|
+
const trackEvent: TrackEvent = {
|
|
321
|
+
type: "track",
|
|
322
|
+
id: generateTrackEventId(),
|
|
323
|
+
orgId: this._options.orgId,
|
|
324
|
+
projectId: this._options.projectId,
|
|
325
|
+
env: this._options.env,
|
|
326
|
+
unitKey: options?.unitKey || "",
|
|
327
|
+
timestamp: new Date().toISOString(),
|
|
328
|
+
event,
|
|
329
|
+
value,
|
|
330
|
+
properties,
|
|
331
|
+
decisionId: options?.decisionId,
|
|
332
|
+
attribution,
|
|
333
|
+
sdkName: SDK_NAME,
|
|
334
|
+
sdkVersion: SDK_VERSION,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
this._eventBatcher.log(trackEvent);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @deprecated Use track() instead.
|
|
342
|
+
* Tracks a reward event.
|
|
343
|
+
* If decisionId is provided and the decision is cached, attribution is auto-populated.
|
|
344
|
+
*/
|
|
345
|
+
trackReward(options: TrackOptions): void {
|
|
346
|
+
// Map old API to new track() API
|
|
347
|
+
this.track(options.event, options.properties, {
|
|
348
|
+
decisionId: undefined, // Not available in old API without decisionId
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ===========================================================================
|
|
353
|
+
// Private Methods
|
|
354
|
+
// ===========================================================================
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Gets the effective bundle: remote > local > null
|
|
358
|
+
*/
|
|
359
|
+
private _getEffectiveBundle(): ConfigBundle | null {
|
|
360
|
+
return this._state.bundle ?? this._options.localConfig ?? null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Fetches the config bundle from the edge worker.
|
|
365
|
+
* Uses ETag for efficient caching.
|
|
366
|
+
*/
|
|
367
|
+
private async _fetchConfig(): Promise<void> {
|
|
368
|
+
const url = `${this._options.baseUrl}/v1/config/${this._options.projectId}?env=${this._options.env}`;
|
|
369
|
+
|
|
370
|
+
const headers: Record<string, string> = {
|
|
371
|
+
"Content-Type": "application/json",
|
|
372
|
+
Authorization: `Bearer ${this._options.apiKey}`,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Add ETag for conditional request
|
|
376
|
+
if (this._state.etag) {
|
|
377
|
+
headers["If-None-Match"] = this._state.etag;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
382
|
+
|
|
383
|
+
if (response.status === 304) {
|
|
384
|
+
// Not modified - bundle is still valid
|
|
385
|
+
this._state.lastFetchTime = Date.now();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!response.ok) {
|
|
390
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const bundle = (await response.json()) as ConfigBundle;
|
|
394
|
+
const etag = response.headers.get("ETag");
|
|
395
|
+
|
|
396
|
+
this._state.bundle = bundle;
|
|
397
|
+
this._state.etag = etag;
|
|
398
|
+
this._state.lastFetchTime = Date.now();
|
|
399
|
+
} catch (error) {
|
|
400
|
+
this._logOfflineWarning(error);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Starts background refresh timer.
|
|
406
|
+
*/
|
|
407
|
+
private _startBackgroundRefresh(): void {
|
|
408
|
+
if (this._options.refreshIntervalMs <= 0) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
this._state.refreshTimer = setInterval(() => {
|
|
413
|
+
this._fetchConfig().catch(() => {
|
|
414
|
+
// Errors are logged in _fetchConfig
|
|
415
|
+
});
|
|
416
|
+
}, this._options.refreshIntervalMs);
|
|
417
|
+
|
|
418
|
+
// Unref so timer doesn't keep process alive
|
|
419
|
+
if (typeof this._state.refreshTimer.unref === "function") {
|
|
420
|
+
this._state.refreshTimer.unref();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Logs an offline warning (rate-limited).
|
|
426
|
+
*/
|
|
427
|
+
private _logOfflineWarning(error: unknown): void {
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
if (now - this._state.lastOfflineWarning > OFFLINE_WARNING_INTERVAL_MS) {
|
|
430
|
+
console.warn(
|
|
431
|
+
`[Traffical] Failed to fetch config: ${error instanceof Error ? error.message : String(error)}. Using ${this._state.bundle ? "cached" : "local"} config.`
|
|
432
|
+
);
|
|
433
|
+
this._state.lastOfflineWarning = now;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Tracks a decision event (internal).
|
|
439
|
+
* Called automatically when trackDecisions is enabled.
|
|
440
|
+
*/
|
|
441
|
+
private _trackDecision(
|
|
442
|
+
decision: DecisionResult,
|
|
443
|
+
latencyMs: number,
|
|
444
|
+
requestedParameters: string[]
|
|
445
|
+
): void {
|
|
446
|
+
const unitKey = decision.metadata.unitKeyValue;
|
|
447
|
+
if (!unitKey) {
|
|
448
|
+
// Can't track without unit key
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Hash assignments for deduplication
|
|
453
|
+
const hash = DecisionDeduplicator.hashAssignments(decision.assignments);
|
|
454
|
+
|
|
455
|
+
// Check deduplication
|
|
456
|
+
if (!this._decisionDedup.checkAndMark(unitKey, hash)) {
|
|
457
|
+
return; // Duplicate, skip
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Build the decision event
|
|
461
|
+
const event: DecisionEvent = {
|
|
462
|
+
type: "decision",
|
|
463
|
+
id: decision.decisionId,
|
|
464
|
+
orgId: this._options.orgId,
|
|
465
|
+
projectId: this._options.projectId,
|
|
466
|
+
env: this._options.env,
|
|
467
|
+
unitKey,
|
|
468
|
+
timestamp: decision.metadata.timestamp,
|
|
469
|
+
requestedParameters,
|
|
470
|
+
assignments: decision.assignments,
|
|
471
|
+
layers: decision.metadata.layers,
|
|
472
|
+
latencyMs,
|
|
473
|
+
// Include filtered context if available
|
|
474
|
+
context: decision.metadata.filteredContext,
|
|
475
|
+
sdkName: SDK_NAME,
|
|
476
|
+
sdkVersion: SDK_VERSION,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
this._eventBatcher.log(event);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Caches a decision for attribution lookup when trackReward is called.
|
|
484
|
+
* Maintains a bounded cache to prevent memory leaks.
|
|
485
|
+
*/
|
|
486
|
+
private _cacheDecision(decision: DecisionResult): void {
|
|
487
|
+
// Evict oldest entries if cache is full
|
|
488
|
+
if (this._decisionCache.size >= DECISION_CACHE_MAX_SIZE) {
|
|
489
|
+
// Get first (oldest) key and delete it
|
|
490
|
+
const firstKey = this._decisionCache.keys().next().value;
|
|
491
|
+
if (firstKey) {
|
|
492
|
+
this._decisionCache.delete(firstKey);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
this._decisionCache.set(decision.decisionId, decision);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Gets attribution info from cached decision if available.
|
|
500
|
+
*/
|
|
501
|
+
private _getAttributionFromCache(decisionId?: string): TrackAttribution[] | undefined {
|
|
502
|
+
if (!decisionId) {
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const cachedDecision = this._decisionCache.get(decisionId);
|
|
507
|
+
if (!cachedDecision) {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const attribution = cachedDecision.metadata.layers
|
|
512
|
+
.filter((l) => l.policyId && l.allocationName)
|
|
513
|
+
.map((l) => ({
|
|
514
|
+
layerId: l.layerId,
|
|
515
|
+
policyId: l.policyId!,
|
|
516
|
+
allocationName: l.allocationName!,
|
|
517
|
+
}));
|
|
518
|
+
|
|
519
|
+
return attribution.length > 0 ? attribution : undefined;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// =============================================================================
|
|
524
|
+
// Factory Function
|
|
525
|
+
// =============================================================================
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Creates and initializes a Traffical client.
|
|
529
|
+
*
|
|
530
|
+
* @example
|
|
531
|
+
* ```typescript
|
|
532
|
+
* const traffical = await createTrafficalClient({
|
|
533
|
+
* orgId: "org_123",
|
|
534
|
+
* projectId: "proj_456",
|
|
535
|
+
* env: "production",
|
|
536
|
+
* apiKey: "sk_...",
|
|
537
|
+
* });
|
|
538
|
+
*
|
|
539
|
+
* const params = traffical.getParams({
|
|
540
|
+
* context: { userId: "user_789" },
|
|
541
|
+
* defaults: {
|
|
542
|
+
* "ui.button.color": "#000",
|
|
543
|
+
* },
|
|
544
|
+
* });
|
|
545
|
+
* ```
|
|
546
|
+
*/
|
|
547
|
+
export async function createTrafficalClient(
|
|
548
|
+
options: TrafficalClientOptions
|
|
549
|
+
): Promise<TrafficalClient> {
|
|
550
|
+
const client = new TrafficalClient(options);
|
|
551
|
+
await client.initialize();
|
|
552
|
+
return client;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Creates a Traffical client without initializing (synchronous).
|
|
557
|
+
* Useful when you want to control initialization timing.
|
|
558
|
+
*/
|
|
559
|
+
export function createTrafficalClientSync(
|
|
560
|
+
options: TrafficalClientOptions
|
|
561
|
+
): TrafficalClient {
|
|
562
|
+
return new TrafficalClient(options);
|
|
563
|
+
}
|
|
564
|
+
|