@traffical/svelte 0.1.0

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,445 @@
1
+ /**
2
+ * @traffical/svelte - Hooks
3
+ *
4
+ * Svelte 5 hooks for parameter resolution and decision tracking.
5
+ * Uses runes ($derived, $effect) for reactive, fine-grained updates.
6
+ */
7
+
8
+ import { resolveParameters, decide as coreDecide } from "@traffical/core";
9
+ import type {
10
+ ParameterValue,
11
+ DecisionResult,
12
+ Context,
13
+ } from "@traffical/core";
14
+ import type {
15
+ TrafficalPlugin,
16
+ TrafficalClient,
17
+ } from "@traffical/js-client";
18
+ import { getTrafficalContext } from "./context.svelte.js";
19
+ import type {
20
+ UseTrafficalOptions,
21
+ UseTrafficalResult,
22
+ BoundTrackRewardOptions,
23
+ TrackRewardOptions,
24
+ TrackEventOptions,
25
+ } from "./types.js";
26
+
27
+ // =============================================================================
28
+ // Browser Detection
29
+ // =============================================================================
30
+
31
+ function isBrowser(): boolean {
32
+ return typeof window !== "undefined" && typeof document !== "undefined";
33
+ }
34
+
35
+ // =============================================================================
36
+ // useTraffical - Primary Hook
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Primary hook for Traffical parameter resolution and decision tracking.
41
+ *
42
+ * Returns reactive values that automatically update when the config bundle changes.
43
+ * On first render, returns defaults immediately (no blocking).
44
+ * When the config bundle loads, recomputes and returns resolved values.
45
+ *
46
+ * @example
47
+ * ```svelte
48
+ * <script>
49
+ * import { useTraffical } from '@traffical/svelte';
50
+ *
51
+ * // Full tracking (default) - decision + exposure events
52
+ * const { params, decision, ready } = useTraffical({
53
+ * defaults: { "checkout.ctaText": "Buy Now" },
54
+ * });
55
+ * </script>
56
+ *
57
+ * {#if ready}
58
+ * <button>{params['checkout.ctaText']}</button>
59
+ * {:else}
60
+ * <button disabled>Loading...</button>
61
+ * {/if}
62
+ * ```
63
+ *
64
+ * @example
65
+ * ```svelte
66
+ * <script>
67
+ * // Decision tracking only - manual exposure control
68
+ * const { params, decision, trackExposure } = useTraffical({
69
+ * defaults: { "checkout.ctaText": "Buy Now" },
70
+ * tracking: "decision",
71
+ * });
72
+ *
73
+ * // Track exposure when element becomes visible
74
+ * function handleVisible() {
75
+ * trackExposure();
76
+ * }
77
+ * </script>
78
+ * ```
79
+ *
80
+ * @example
81
+ * ```svelte
82
+ * <script>
83
+ * // No tracking - for SSR, tests, or internal logic
84
+ * const { params, ready } = useTraffical({
85
+ * defaults: { "ui.hero.title": "Welcome" },
86
+ * tracking: "none",
87
+ * });
88
+ * </script>
89
+ * ```
90
+ */
91
+ export function useTraffical<T extends Record<string, ParameterValue>>(
92
+ options: UseTrafficalOptions<T>
93
+ ): UseTrafficalResult<T> {
94
+ const ctx = getTrafficalContext();
95
+
96
+ const trackingMode = options.tracking ?? "full";
97
+ const shouldTrackDecision = trackingMode !== "none";
98
+ const shouldAutoTrackExposure = trackingMode === "full";
99
+
100
+ // Track whether we've already tracked exposure for this decision
101
+ let hasTrackedExposure = $state(false);
102
+ let currentDecisionId = $state<string | null>(null);
103
+
104
+ // Derive params reactively using $derived.by
105
+ // This is synchronous and provides fine-grained reactivity
106
+ const params = $derived.by((): T => {
107
+ // Priority 1: Resolve from bundle if available
108
+ if (ctx.bundle) {
109
+ const context: Context = {
110
+ ...ctx.getContext(),
111
+ ...options.context,
112
+ };
113
+ return resolveParameters(ctx.bundle, context, options.defaults);
114
+ }
115
+
116
+ // Priority 2: Use server-provided initial params
117
+ if (ctx.initialParams) {
118
+ return { ...options.defaults, ...ctx.initialParams } as T;
119
+ }
120
+
121
+ // Priority 3: Fall back to defaults
122
+ return options.defaults;
123
+ });
124
+
125
+ // Derive decision reactively
126
+ const decision = $derived.by((): DecisionResult | null => {
127
+ if (!shouldTrackDecision) {
128
+ return null;
129
+ }
130
+
131
+ if (!ctx.bundle) {
132
+ return null;
133
+ }
134
+
135
+ const context: Context = {
136
+ ...ctx.getContext(),
137
+ ...options.context,
138
+ };
139
+
140
+ // Use client's decide if available (handles tracking internally)
141
+ if (ctx.client) {
142
+ return ctx.client.decide({
143
+ context,
144
+ defaults: options.defaults,
145
+ });
146
+ }
147
+
148
+ // Fall back to core decide (SSR or no client)
149
+ return coreDecide(ctx.bundle, context, options.defaults);
150
+ });
151
+
152
+ // Reset exposure tracking when decision changes
153
+ $effect(() => {
154
+ const decisionId = decision?.decisionId ?? null;
155
+ if (decisionId !== currentDecisionId) {
156
+ currentDecisionId = decisionId;
157
+ hasTrackedExposure = false;
158
+ }
159
+ });
160
+
161
+ // Auto-track exposure when tracking is "full" and decision is available
162
+ $effect(() => {
163
+ if (
164
+ shouldAutoTrackExposure &&
165
+ decision &&
166
+ !hasTrackedExposure &&
167
+ isBrowser()
168
+ ) {
169
+ trackExposureInternal();
170
+ }
171
+ });
172
+
173
+ // Internal exposure tracking function
174
+ function trackExposureInternal(): void {
175
+ if (!isBrowser() || !ctx.client || !decision || hasTrackedExposure) {
176
+ return;
177
+ }
178
+
179
+ ctx.client.trackExposure(decision);
180
+ hasTrackedExposure = true;
181
+ }
182
+
183
+ // Public exposure tracking function
184
+ function trackExposure(): void {
185
+ if (trackingMode === "none") {
186
+ return;
187
+ }
188
+ trackExposureInternal();
189
+ }
190
+
191
+ // Track user events - decisionId is automatically included
192
+ function track(event: string, properties?: Record<string, unknown>): void {
193
+ if (!isBrowser() || !ctx.client) {
194
+ if (!isBrowser()) {
195
+ return; // Silent no-op during SSR
196
+ }
197
+ console.warn("[Traffical] Client not initialized, cannot track event");
198
+ return;
199
+ }
200
+
201
+ // Access the reactive decision value
202
+ const currentDecision = decision;
203
+ if (!currentDecision) {
204
+ console.warn(
205
+ "[Traffical] No decision available, cannot track event. Did you use tracking: 'none'?"
206
+ );
207
+ return;
208
+ }
209
+
210
+ ctx.client.track(event, properties, {
211
+ decisionId: currentDecision.decisionId,
212
+ unitKey: ctx.getUnitKey(),
213
+ });
214
+ }
215
+
216
+ // Deprecated: Bound reward tracking - decisionId is automatically included
217
+ function trackReward(options: BoundTrackRewardOptions): void {
218
+ if (!isBrowser() || !ctx.client) {
219
+ if (!isBrowser()) {
220
+ return; // Silent no-op during SSR
221
+ }
222
+ console.warn("[Traffical] Client not initialized, cannot track reward");
223
+ return;
224
+ }
225
+
226
+ // Access the reactive decision value
227
+ const currentDecision = decision;
228
+ if (!currentDecision) {
229
+ console.warn(
230
+ "[Traffical] No decision available, cannot track reward. Did you use tracking: 'none'?"
231
+ );
232
+ return;
233
+ }
234
+
235
+ // Map old API to new track() API
236
+ track(options.rewardType || "reward", {
237
+ value: options.reward,
238
+ ...(options.rewards ? { rewards: options.rewards } : {}),
239
+ });
240
+ }
241
+
242
+ return {
243
+ get params() {
244
+ return params;
245
+ },
246
+ get decision() {
247
+ return decision;
248
+ },
249
+ get ready() {
250
+ return ctx.ready;
251
+ },
252
+ get error() {
253
+ return ctx.error;
254
+ },
255
+ trackExposure,
256
+ track,
257
+ trackReward,
258
+ };
259
+ }
260
+
261
+ // =============================================================================
262
+ // useTrafficalTrack
263
+ // =============================================================================
264
+
265
+ /**
266
+ * Hook to track user events.
267
+ *
268
+ * @example
269
+ * ```svelte
270
+ * <script>
271
+ * import { useTrafficalTrack } from '@traffical/svelte';
272
+ *
273
+ * const track = useTrafficalTrack();
274
+ *
275
+ * function handlePurchase(amount: number) {
276
+ * track({
277
+ * event: 'purchase',
278
+ * properties: { value: amount, orderId: 'ord_123' },
279
+ * });
280
+ * }
281
+ * </script>
282
+ * ```
283
+ */
284
+ export function useTrafficalTrack(): (options: TrackEventOptions) => void {
285
+ const ctx = getTrafficalContext();
286
+
287
+ return function track(options: TrackEventOptions): void {
288
+ if (!isBrowser() || !ctx.client) {
289
+ if (!isBrowser()) {
290
+ return; // Silent no-op during SSR
291
+ }
292
+ console.warn("[Traffical] Client not initialized, cannot track event");
293
+ return;
294
+ }
295
+
296
+ ctx.client.track(options.event, options.properties, {
297
+ decisionId: options.decisionId,
298
+ unitKey: ctx.getUnitKey(),
299
+ });
300
+ };
301
+ }
302
+
303
+ // =============================================================================
304
+ // useTrafficalReward (deprecated)
305
+ // =============================================================================
306
+
307
+ /**
308
+ * @deprecated Use useTrafficalTrack() instead.
309
+ *
310
+ * Hook to track rewards (conversions, revenue, etc.).
311
+ *
312
+ * @example
313
+ * ```svelte
314
+ * <script>
315
+ * import { useTraffical, useTrafficalReward } from '@traffical/svelte';
316
+ *
317
+ * const { params, decision } = useTraffical({
318
+ * defaults: { 'checkout.ctaText': 'Buy Now' },
319
+ * });
320
+ *
321
+ * const trackReward = useTrafficalReward();
322
+ *
323
+ * function handlePurchase(amount: number) {
324
+ * trackReward({
325
+ * reward: amount,
326
+ * rewardType: 'revenue',
327
+ * });
328
+ * }
329
+ * </script>
330
+ * ```
331
+ */
332
+ export function useTrafficalReward(): (options: TrackRewardOptions) => void {
333
+ const ctx = getTrafficalContext();
334
+
335
+ return function trackReward(options: TrackRewardOptions): void {
336
+ if (!isBrowser() || !ctx.client) {
337
+ if (!isBrowser()) {
338
+ return; // Silent no-op during SSR
339
+ }
340
+ console.warn("[Traffical] Client not initialized, cannot track reward");
341
+ return;
342
+ }
343
+
344
+ // We need to ensure decisionId is provided for the core TrackRewardOptions
345
+ // If not provided, we skip tracking (can't attribute without decision)
346
+ if (!options.decisionId) {
347
+ console.warn("[Traffical] trackReward called without decisionId, skipping");
348
+ return;
349
+ }
350
+
351
+ // Map old API to new track() API
352
+ ctx.client.track(options.rewardType || "reward", {
353
+ value: options.reward,
354
+ ...(options.rewards ? { rewards: options.rewards } : {}),
355
+ }, {
356
+ decisionId: options.decisionId,
357
+ unitKey: ctx.getUnitKey(),
358
+ });
359
+ };
360
+ }
361
+
362
+ // =============================================================================
363
+ // useTrafficalClient
364
+ // =============================================================================
365
+
366
+ /**
367
+ * Hook to access the Traffical client directly.
368
+ *
369
+ * @example
370
+ * ```svelte
371
+ * <script>
372
+ * import { useTrafficalClient } from '@traffical/svelte';
373
+ *
374
+ * const { client, ready, error } = useTrafficalClient();
375
+ *
376
+ * $effect(() => {
377
+ * if (ready && client) {
378
+ * const version = client.getConfigVersion();
379
+ * const stableId = client.getStableId();
380
+ * console.log('Config version:', version, 'Stable ID:', stableId);
381
+ * }
382
+ * });
383
+ * </script>
384
+ * ```
385
+ */
386
+ export function useTrafficalClient(): {
387
+ readonly client: TrafficalClient | null;
388
+ readonly ready: boolean;
389
+ readonly error: Error | null;
390
+ } {
391
+ const ctx = getTrafficalContext();
392
+
393
+ return {
394
+ get client() {
395
+ return ctx.client;
396
+ },
397
+ get ready() {
398
+ return ctx.ready;
399
+ },
400
+ get error() {
401
+ return ctx.error;
402
+ },
403
+ };
404
+ }
405
+
406
+ // =============================================================================
407
+ // useTrafficalPlugin
408
+ // =============================================================================
409
+
410
+ /**
411
+ * Hook to access a registered plugin by name.
412
+ *
413
+ * @example
414
+ * ```svelte
415
+ * <script>
416
+ * import { useTrafficalPlugin } from '@traffical/svelte';
417
+ * import type { DOMBindingPlugin } from '@traffical/js-client';
418
+ *
419
+ * const domPlugin = useTrafficalPlugin<DOMBindingPlugin>('dom-binding');
420
+ *
421
+ * // Re-apply bindings after dynamic content changes
422
+ * $effect(() => {
423
+ * if (contentLoaded) {
424
+ * domPlugin?.applyBindings();
425
+ * }
426
+ * });
427
+ * </script>
428
+ * ```
429
+ */
430
+ export function useTrafficalPlugin<
431
+ T extends TrafficalPlugin = TrafficalPlugin,
432
+ >(name: string): T | undefined {
433
+ const ctx = getTrafficalContext();
434
+
435
+ // Derive plugin access reactively
436
+ const plugin = $derived.by((): T | undefined => {
437
+ if (!ctx.client || !ctx.ready) {
438
+ return undefined;
439
+ }
440
+ return ctx.client.getPlugin(name) as T | undefined;
441
+ });
442
+
443
+ return plugin;
444
+ }
445
+
@@ -0,0 +1,221 @@
1
+ /**
2
+ * @traffical/svelte - Unit Tests
3
+ *
4
+ * Tests for the Svelte 5 SDK hooks and utilities.
5
+ */
6
+
7
+ import { describe, test, expect } from "bun:test";
8
+ import { resolveParameters } from "@traffical/core";
9
+ import type { ConfigBundle } from "@traffical/core";
10
+
11
+ // =============================================================================
12
+ // Test Fixtures
13
+ // =============================================================================
14
+
15
+ const mockBundle: ConfigBundle = {
16
+ version: new Date().toISOString(),
17
+ orgId: "org_test",
18
+ projectId: "proj_test",
19
+ env: "test",
20
+ hashing: {
21
+ unitKey: "userId",
22
+ bucketCount: 10000,
23
+ },
24
+ parameters: [
25
+ {
26
+ key: "checkout.ctaText",
27
+ type: "string",
28
+ default: "Buy Now",
29
+ layerId: "layer_1",
30
+ namespace: "checkout",
31
+ },
32
+ {
33
+ key: "checkout.ctaColor",
34
+ type: "string",
35
+ default: "#000000",
36
+ layerId: "layer_1",
37
+ namespace: "checkout",
38
+ },
39
+ {
40
+ key: "feature.newCheckout",
41
+ type: "boolean",
42
+ default: false,
43
+ layerId: "layer_2",
44
+ namespace: "feature",
45
+ },
46
+ ],
47
+ layers: [
48
+ {
49
+ id: "layer_1",
50
+ policies: [],
51
+ },
52
+ {
53
+ id: "layer_2",
54
+ policies: [],
55
+ },
56
+ ],
57
+ domBindings: [],
58
+ };
59
+
60
+ const mockBundleWithLayer: ConfigBundle = {
61
+ ...mockBundle,
62
+ layers: [
63
+ {
64
+ id: "layer_1",
65
+ policies: [
66
+ {
67
+ id: "policy_1",
68
+ state: "running",
69
+ kind: "static",
70
+ conditions: [],
71
+ allocations: [
72
+ {
73
+ id: "alloc_1",
74
+ name: "control",
75
+ bucketRange: [0, 4999] as [number, number],
76
+ overrides: {
77
+ "checkout.ctaText": "Buy Now",
78
+ "checkout.ctaColor": "#000000",
79
+ },
80
+ },
81
+ {
82
+ id: "alloc_2",
83
+ name: "treatment",
84
+ bucketRange: [5000, 9999] as [number, number],
85
+ overrides: {
86
+ "checkout.ctaText": "Purchase",
87
+ "checkout.ctaColor": "#FF0000",
88
+ },
89
+ },
90
+ ],
91
+ },
92
+ ],
93
+ },
94
+ {
95
+ id: "layer_2",
96
+ policies: [],
97
+ },
98
+ ],
99
+ };
100
+
101
+ // =============================================================================
102
+ // Resolution Tests
103
+ // =============================================================================
104
+
105
+ describe("resolveParameters", () => {
106
+ test("returns defaults when bundle is null", () => {
107
+ const defaults = {
108
+ "checkout.ctaText": "Default Text",
109
+ "checkout.ctaColor": "#FFFFFF",
110
+ };
111
+
112
+ const result = resolveParameters(null, {}, defaults);
113
+ expect(result).toEqual(defaults);
114
+ });
115
+
116
+ test("resolves parameters from bundle defaults", () => {
117
+ const defaults = {
118
+ "checkout.ctaText": "Fallback",
119
+ "checkout.ctaColor": "#FFFFFF",
120
+ };
121
+
122
+ const result = resolveParameters(mockBundle, { userId: "user_123" }, defaults);
123
+
124
+ expect(result["checkout.ctaText"]).toBe("Buy Now");
125
+ expect(result["checkout.ctaColor"]).toBe("#000000");
126
+ });
127
+
128
+ test("returns defaults for missing parameters", () => {
129
+ const defaults = {
130
+ "checkout.ctaText": "Fallback",
131
+ "nonexistent.param": "Default Value",
132
+ };
133
+
134
+ const result = resolveParameters(mockBundle, { userId: "user_123" }, defaults);
135
+
136
+ expect(result["checkout.ctaText"]).toBe("Buy Now");
137
+ expect(result["nonexistent.param"]).toBe("Default Value");
138
+ });
139
+
140
+ test("resolves boolean parameters correctly", () => {
141
+ const defaults = {
142
+ "feature.newCheckout": true, // Default to true, bundle has false
143
+ };
144
+
145
+ const result = resolveParameters(mockBundle, { userId: "user_123" }, defaults);
146
+
147
+ expect(result["feature.newCheckout"]).toBe(false);
148
+ });
149
+ });
150
+
151
+ // =============================================================================
152
+ // SSR Behavior Tests
153
+ // =============================================================================
154
+
155
+ describe("SSR behavior", () => {
156
+ test("isBrowser returns false in test environment", () => {
157
+ // In Bun test environment, window is not defined
158
+ const isBrowser =
159
+ typeof window !== "undefined" && typeof document !== "undefined";
160
+ expect(isBrowser).toBe(false);
161
+ });
162
+
163
+ test("resolveParameters works without browser APIs", () => {
164
+ // This verifies that core resolution doesn't depend on browser APIs
165
+ const defaults = {
166
+ "checkout.ctaText": "Fallback",
167
+ };
168
+
169
+ const result = resolveParameters(mockBundle, { userId: "ssr_user" }, defaults);
170
+
171
+ expect(result["checkout.ctaText"]).toBe("Buy Now");
172
+ });
173
+ });
174
+
175
+ // =============================================================================
176
+ // Type Safety Tests
177
+ // =============================================================================
178
+
179
+ describe("type safety", () => {
180
+ test("preserves type inference for defaults", () => {
181
+ const defaults = {
182
+ stringParam: "hello",
183
+ numberParam: 42,
184
+ booleanParam: true,
185
+ } as const;
186
+
187
+ type Defaults = typeof defaults;
188
+
189
+ // Type check - this should compile
190
+ const result: Defaults = resolveParameters(
191
+ mockBundle,
192
+ {},
193
+ defaults
194
+ ) as Defaults;
195
+
196
+ expect(typeof result.stringParam).toBe("string");
197
+ expect(typeof result.numberParam).toBe("number");
198
+ expect(typeof result.booleanParam).toBe("boolean");
199
+ });
200
+ });
201
+
202
+ // =============================================================================
203
+ // Bundle Validation Tests
204
+ // =============================================================================
205
+
206
+ describe("bundle structure", () => {
207
+ test("mock bundle has expected structure", () => {
208
+ expect(mockBundle.orgId).toBe("org_test");
209
+ expect(mockBundle.hashing.unitKey).toBe("userId");
210
+ expect(mockBundle.hashing.bucketCount).toBe(10000);
211
+ expect(mockBundle.parameters).toHaveLength(3);
212
+ expect(mockBundle.layers).toHaveLength(2);
213
+ });
214
+
215
+ test("mock bundle with layer has allocations", () => {
216
+ expect(mockBundleWithLayer.layers).toHaveLength(2);
217
+ expect(mockBundleWithLayer.layers[0].policies).toHaveLength(1);
218
+ expect(mockBundleWithLayer.layers[0].policies[0].allocations).toHaveLength(2);
219
+ });
220
+ });
221
+