@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.
@@ -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
+