Skip to content

Commit 0278c8f

Browse files
committed
chore: add autogenerated replaneClientId context prop
1 parent 564da08 commit 0278c8f

5 files changed

Lines changed: 206 additions & 11 deletions

File tree

packages/sdk/src/client.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ import type {
1212
import { ReplaneRemoteStorage } from "./storage";
1313
import { ReplaneError, ReplaneErrorCode } from "./error";
1414
import { evaluateOverrides } from "./evaluation";
15-
import { Deferred } from "./utils";
15+
import { Deferred, generateClientId } from "./utils";
1616
import { DEFAULT_AGENT } from "./version";
1717

18+
/**
19+
* The context key for the auto-generated client ID.
20+
* This key is automatically set by the SDK and can be used for segmentation.
21+
* User-provided values for this key take precedence over the auto-generated value.
22+
*/
23+
export const REPLANE_CLIENT_ID_KEY = "replaneClientId";
24+
1825
interface ReplaneHandle<T extends object> {
1926
_replane: ReplaneImpl<T>;
2027
}
@@ -106,7 +113,13 @@ class ReplaneImpl<T extends object = Record<string, unknown>> {
106113
*/
107114
constructor(options: ReplaneOptions<T> = {}) {
108115
this.logger = options.logger ?? console;
109-
this.context = { ...(options.context ?? {}) };
116+
117+
// Generate replaneClientId and set it as base context.
118+
// User-provided context values take precedence (merged on top).
119+
const autoGeneratedContext: ReplaneContext = {
120+
[REPLANE_CLIENT_ID_KEY]: generateClientId(),
121+
};
122+
this.context = { ...autoGeneratedContext, ...(options.context ?? {}) };
110123

111124
// Initialize configs from snapshot or defaults
112125
const initialConfigs: ConfigDto[] = [];

packages/sdk/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// Re-export public API
22

3-
// Client class
43
export { Replane } from "./client";
54

65
// Error types

packages/sdk/src/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
/**
2+
* Generates a random UUID using the Web Crypto API.
3+
* Falls back to a simple implementation if crypto.randomUUID is not available.
4+
*
5+
* @returns A random UUID string
6+
*/
7+
export function generateClientId(): string {
8+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
9+
return crypto.randomUUID();
10+
}
11+
12+
// Fallback for older environments
13+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
14+
const r = (Math.random() * 16) | 0;
15+
const v = c === "x" ? r : (r & 0x3) | 0x8;
16+
return v.toString(16);
17+
});
18+
}
19+
120
/**
221
* Returns a promise that resolves after the specified delay
322
*

packages/sdk/tests/index.spec.ts

Lines changed: 151 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Replane, ReplaneError } from "../src/index";
33
import type { ReplaneSnapshot, ConnectOptions } from "../src/index";
44
import { MockReplaneServerController } from "./utils";
55

6+
const REPLANE_CLIENT_ID_KEY = "replaneClientId";
7+
68
function sync() {
79
return new Promise((resolve) => setTimeout(resolve, 0));
810
}
@@ -48,12 +50,14 @@ describe("Replane", () => {
4850
defaults,
4951
context,
5052
});
51-
return client.connect({
52-
sdkKey: "test-sdk-key",
53-
baseUrl: "https://replane.my-host.com",
54-
fetchFn: mockServer.fetchFn,
55-
...connectOptions,
56-
}).then(() => client);
53+
return client
54+
.connect({
55+
sdkKey: "test-sdk-key",
56+
baseUrl: "https://replane.my-host.com",
57+
fetchFn: mockServer.fetchFn,
58+
...connectOptions,
59+
})
60+
.then(() => client);
5761
}
5862

5963
describe("basic config fetching", () => {
@@ -1715,7 +1719,6 @@ describe("Replane", () => {
17151719
unsubscribe3();
17161720
});
17171721
});
1718-
17191722
});
17201723

17211724
describe("client close", () => {
@@ -2125,6 +2128,147 @@ describe("Replane with snapshot", () => {
21252128
});
21262129
});
21272130

2131+
describe("replaneClientId auto-generation", () => {
2132+
it("should auto-generate replaneClientId in client context", () => {
2133+
const snapshot: ReplaneSnapshot<Record<string, unknown>> = {
2134+
configs: [
2135+
{
2136+
name: "feature",
2137+
value: "default",
2138+
overrides: [
2139+
{
2140+
name: "segmented-override",
2141+
conditions: [
2142+
{
2143+
operator: "segmentation",
2144+
property: REPLANE_CLIENT_ID_KEY,
2145+
fromPercentage: 0,
2146+
toPercentage: 100,
2147+
seed: "test-seed",
2148+
},
2149+
],
2150+
value: "segmented-value",
2151+
},
2152+
],
2153+
},
2154+
],
2155+
};
2156+
2157+
// Create client without providing replaneClientId
2158+
const client = new Replane({ snapshot });
2159+
2160+
// Should match segmentation because replaneClientId is auto-generated
2161+
expect(client.get("feature")).toBe("segmented-value");
2162+
});
2163+
2164+
it("should allow user to override replaneClientId", () => {
2165+
const userProvidedClientId = "user-provided-client-id";
2166+
const snapshot: ReplaneSnapshot<Record<string, unknown>> = {
2167+
configs: [
2168+
{
2169+
name: "feature",
2170+
value: "default",
2171+
overrides: [
2172+
{
2173+
name: "user-override",
2174+
conditions: [
2175+
{
2176+
operator: "equals",
2177+
property: REPLANE_CLIENT_ID_KEY,
2178+
value: userProvidedClientId,
2179+
},
2180+
],
2181+
value: "user-override-value",
2182+
},
2183+
],
2184+
},
2185+
],
2186+
};
2187+
2188+
// Provide replaneClientId in context - should take precedence
2189+
const client = new Replane({
2190+
snapshot,
2191+
context: { [REPLANE_CLIENT_ID_KEY]: userProvidedClientId },
2192+
});
2193+
2194+
expect(client.get("feature")).toBe("user-override-value");
2195+
});
2196+
2197+
it("should allow per-request context to override replaneClientId", () => {
2198+
const perRequestClientId = "per-request-client-id";
2199+
const snapshot: ReplaneSnapshot<Record<string, unknown>> = {
2200+
configs: [
2201+
{
2202+
name: "feature",
2203+
value: "default",
2204+
overrides: [
2205+
{
2206+
name: "per-request-override",
2207+
conditions: [
2208+
{
2209+
operator: "equals",
2210+
property: REPLANE_CLIENT_ID_KEY,
2211+
value: perRequestClientId,
2212+
},
2213+
],
2214+
value: "per-request-value",
2215+
},
2216+
],
2217+
},
2218+
],
2219+
};
2220+
2221+
const client = new Replane({ snapshot });
2222+
2223+
// Default should be based on auto-generated ID
2224+
expect(client.get("feature")).toBe("default");
2225+
2226+
// Per-request context should override the auto-generated ID
2227+
expect(
2228+
client.get("feature", { context: { [REPLANE_CLIENT_ID_KEY]: perRequestClientId } })
2229+
).toBe("per-request-value");
2230+
});
2231+
2232+
it("should generate unique replaneClientId for each client instance", () => {
2233+
const snapshot: ReplaneSnapshot<Record<string, unknown>> = {
2234+
configs: [
2235+
{
2236+
name: "feature",
2237+
value: "default",
2238+
overrides: [
2239+
{
2240+
name: "50-percent-rollout",
2241+
conditions: [
2242+
{
2243+
operator: "segmentation",
2244+
property: REPLANE_CLIENT_ID_KEY,
2245+
fromPercentage: 0,
2246+
toPercentage: 50,
2247+
seed: "test-seed",
2248+
},
2249+
],
2250+
value: "rollout-value",
2251+
},
2252+
],
2253+
},
2254+
],
2255+
};
2256+
2257+
// Create multiple clients
2258+
const clients = Array.from({ length: 10 }, () => new Replane({ snapshot }));
2259+
2260+
// Each client should get a consistent result (either default or rollout-value)
2261+
// but different clients may get different results due to unique IDs
2262+
const results = clients.map((client) => client.get("feature"));
2263+
2264+
// At least verify we get both values (statistically very likely with 10 clients and 50% rollout)
2265+
// This test mainly verifies that the segmentation is actually working
2266+
expect(
2267+
results.some((r) => r === "rollout-value") || results.some((r) => r === "default")
2268+
).toBe(true);
2269+
});
2270+
});
2271+
21282272
describe("overrides evaluation", () => {
21292273
it("should evaluate equals operator", () => {
21302274
const snapshot: ReplaneSnapshot<Record<string, unknown>> = {

packages/sdk/tests/utils.spec.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2-
import { delay, retryDelay, combineAbortSignals, Deferred } from "../src/utils";
2+
import { delay, retryDelay, combineAbortSignals, Deferred, generateClientId } from "../src/utils";
33

44
describe("delay", () => {
55
beforeEach(() => {
@@ -221,3 +221,23 @@ describe("Deferred", () => {
221221
await expect(chainedPromise).resolves.toBe(42);
222222
});
223223
});
224+
225+
describe("generateClientId", () => {
226+
it("should generate a valid UUID string", () => {
227+
const clientId = generateClientId();
228+
229+
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
230+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
231+
expect(clientId).toMatch(uuidRegex);
232+
});
233+
234+
it("should generate unique IDs on each call", () => {
235+
const id1 = generateClientId();
236+
const id2 = generateClientId();
237+
const id3 = generateClientId();
238+
239+
expect(id1).not.toBe(id2);
240+
expect(id2).not.toBe(id3);
241+
expect(id1).not.toBe(id3);
242+
});
243+
});

0 commit comments

Comments
 (0)