Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/context-json-sdk-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@reflag/node-sdk": minor
"@reflag/browser-sdk": minor
"@reflag/react-sdk": minor
---

Send remote evaluation context as canonical `contextJson`, preserving array-valued context attributes. Browser and React SDK context types now accept JSON-compatible array and object values.
98 changes: 94 additions & 4 deletions packages/browser-sdk/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,93 @@
/** A JSON-compatible context value that is not `undefined`. */
export type DefinedContextValue =
| string
| number
| boolean
| null
| DefinedContextValue[]
| { [key: string]: ContextValue };

/** A context value. Object properties set to `undefined` are omitted. */
export type ContextValue = DefinedContextValue | undefined;

/**
* Serialize context with recursively sorted object keys. Array order is preserved.
*/
function canonicalJSONStringify(value: unknown): string {
let serialized: string | undefined;
try {
serialized = JSON.stringify(value, (_key, nestedValue: unknown) => {
if (typeof nestedValue === "bigint") return String(nestedValue);
if (
nestedValue === null ||
typeof nestedValue !== "object" ||
Array.isArray(nestedValue)
) {
return nestedValue;
}

return Object.fromEntries(
Object.entries(nestedValue).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
);
});
} catch {
throw new Error("value must be JSON serializable");
}
if (serialized === undefined) {
throw new Error("value must be JSON serializable");
}
return serialized;
}

function pruneUndefinedObjectValues(
value: object,
ancestors: WeakSet<object>,
): Record<string, unknown> {
if (ancestors.has(value)) {
throw new Error("value must be JSON serializable");
}
ancestors.add(value);

const entries = Object.entries(value).flatMap(([key, nestedValue]) => {
if (nestedValue === undefined) return [];
if (
nestedValue !== null &&
typeof nestedValue === "object" &&
!Array.isArray(nestedValue)
) {
const originalKeys = Object.keys(nestedValue);
const pruned = pruneUndefinedObjectValues(nestedValue, ancestors);
if (originalKeys.length > 0 && Object.keys(pruned).length === 0)
return [];
return [[key, pruned] as const];
}
return [[key, nestedValue] as const];
});

ancestors.delete(value);
return Object.fromEntries(entries);
}

export function canonicalContextJSONStringify(
context: ReflagContext | undefined,
): string | undefined {
if (!context) return undefined;
const pruned = Object.fromEntries(
Object.entries(pruneUndefinedObjectValues(context, new WeakSet())).filter(
([, section]) =>
!section ||
typeof section !== "object" ||
Array.isArray(section) ||
Object.keys(section).length > 0,
),
);
return Object.keys(pruned).length
? canonicalJSONStringify(pruned)
: undefined;
}

/**
* Context is a set of key-value pairs.
* This is used to determine if feature targeting matches and to track events.
Expand All @@ -17,7 +107,7 @@ export interface CompanyContext {
/**
* Other company attributes
*/
[key: string]: string | number | undefined;
[key: string]: ContextValue;
}

/**
Expand All @@ -44,7 +134,7 @@ export interface UserContext {
/**
* Other user attributes
*/
[key: string]: string | number | undefined;
[key: string]: ContextValue;
}

/**
Expand All @@ -67,7 +157,7 @@ export interface ReflagContext {
/**
* Context which is not related to a user or a company.
*/
other?: Record<string, string | number | undefined>;
other?: Record<string, ContextValue>;
}

/**
Expand All @@ -79,5 +169,5 @@ export interface ReflagDeprecatedContext extends ReflagContext {
* Context which is not related to a user or a company.
* @deprecated Use `other` instead, this property will be removed in the next major version
*/
otherContext?: Record<string, string | number | undefined>;
otherContext?: Record<string, ContextValue>;
}
8 changes: 5 additions & 3 deletions packages/browser-sdk/src/flag/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { deepEqual } from "fast-equals";

import type { BulkEvent } from "../bulkQueue";
import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS } from "../config";
import { ReflagContext } from "../context";
import { canonicalContextJSONStringify, ReflagContext } from "../context";
import { HttpClient } from "../httpClient";
import { Logger, loggerWithPrefix } from "../logger";
import RateLimiter from "../rateLimiter";
Expand Down Expand Up @@ -1005,8 +1005,10 @@ export class FlagsClient {
}

private fetchParams() {
const flattenedContext = flattenJSON({ context: this.context });
const params = new URLSearchParams(flattenedContext);
const contextJson = canonicalContextJSONStringify(this.context);
const params = new URLSearchParams(
contextJson ? { contextJson } : undefined,
);
Comment thread
roncohen marked this conversation as resolved.
// publishableKey should be part of the cache key
params.append("publishableKey", this.httpClient.publishableKey);

Expand Down
2 changes: 2 additions & 0 deletions packages/browser-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type {
export { ReflagClient } from "./client";
export type {
CompanyContext,
ContextValue,
DefinedContextValue,
ReflagContext,
ReflagDeprecatedContext,
UserContext,
Expand Down
34 changes: 2 additions & 32 deletions packages/browser-sdk/src/sse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SDK_VERSION_HEADER_NAME } from "./config";
import { ReflagContext } from "./context";
import { canonicalContextJSONStringify, ReflagContext } from "./context";
import { Logger, loggerWithPrefix } from "./logger";

export type EventSourceLike = {
Expand All @@ -17,36 +17,6 @@ export type PubSubMessage = {
[key: string]: any;
};

function withoutUndefinedValues(
attributes: Record<string, string | number | undefined> | undefined,
) {
if (!attributes) return undefined;

const cleaned: Record<string, string | number> = {};
for (const [key, value] of Object.entries(attributes)) {
if (value !== undefined) {
cleaned[key] = value;
}
}

return Object.keys(cleaned).length > 0 ? cleaned : undefined;
}

function serializeContext(context: ReflagContext | undefined) {
if (!context) return undefined;

const payload: Record<string, Record<string, string | number>> = {};
const user = withoutUndefinedValues(context.user);
const company = withoutUndefinedValues(context.company);
const other = withoutUndefinedValues(context.other);

if (user) payload.user = user;
if (company) payload.company = company;
if (other) payload.other = other;

return Object.keys(payload).length > 0 ? JSON.stringify(payload) : undefined;
}

export class AblySSEChannel {
private isOpen = false;
private eventSource: EventSourceLike | null = null;
Expand Down Expand Up @@ -165,7 +135,7 @@ export class AblySSEChannel {
if (this.sdkVersion) {
url.searchParams.append(SDK_VERSION_HEADER_NAME, this.sdkVersion);
}
const serializedContext = serializeContext(this.context);
const serializedContext = canonicalContextJSONStringify(this.context);
if (serializedContext) {
url.searchParams.append("context", serializedContext);
}
Expand Down
21 changes: 14 additions & 7 deletions packages/browser-sdk/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,10 @@ describe("ReflagClient", () => {
({ request }) => {
requests.push("flags");
const url = new URL(request.url);
expect(url.searchParams.get("context.user.siteCentricOptIn")).toBe(
"true",
);
expect(
JSON.parse(url.searchParams.get("contextJson") ?? "{}").user
.siteCentricOptIn,
).toBe("true");

return HttpResponse.json({
success: true,
Expand Down Expand Up @@ -459,9 +460,12 @@ describe("ReflagClient", () => {
http.get(
"https://front.reflag.com/features/evaluated",
({ request }) => {
const userId = new URL(request.url).searchParams.get(
"context.user.id",
const contextJson = new URL(request.url).searchParams.get(
"contextJson",
);
const userId = contextJson
? JSON.parse(contextJson).user?.id
: undefined;
if (userId === "user1") {
return previousContextResponse.promise.then((response) => {
previousContextSettled();
Expand Down Expand Up @@ -873,9 +877,12 @@ describe("ReflagClient", () => {
http.get(
"https://front.reflag.com/features/evaluated",
({ request }) => {
const userId = new URL(request.url).searchParams.get(
"context.user.id",
const contextJson = new URL(request.url).searchParams.get(
"contextJson",
);
const userId = contextJson
? JSON.parse(contextJson).user?.id
: undefined;
return userId === "user1"
? previousContextRefresh.promise
: currentContextRefresh.promise;
Expand Down
38 changes: 38 additions & 0 deletions packages/browser-sdk/test/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";

import { canonicalContextJSONStringify } from "../src/context";

describe("canonicalContextJSONStringify", () => {
it("sorts recursively, preserves arrays, and prunes undefined-only objects", () => {
expect(
canonicalContextJSONStringify({
user: { id: undefined },
other: {
z: 2,
dropped: { value: undefined },
explicitEmpty: {},
values: [{ value: undefined }, "second"],
a: 1,
},
}),
).toBe('{"other":{"a":1,"explicitEmpty":{},"values":[{},"second"],"z":2}}');
});

it("omits an effectively empty context", () => {
expect(
canonicalContextJSONStringify({ user: { id: undefined } }),
).toBeUndefined();
});

it("normalizes bigint and reports circular values consistently", () => {
expect(
canonicalContextJSONStringify({ other: { value: 42n } } as any),
).toBe('{"other":{"value":"42"}}');

const circular: Record<string, unknown> = {};
circular.self = circular;
expect(() =>
canonicalContextJSONStringify({ other: circular } as any),
).toThrow("value must be JSON serializable");
});
});
25 changes: 19 additions & 6 deletions packages/browser-sdk/test/flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,30 @@ describe("FlagsClient", () => {
const paramsObj = Object.fromEntries(new URLSearchParams(params));
expect(paramsObj).toEqual({
"reflag-sdk-version": "browser-sdk/" + version,
"context.user.id": "123",
"context.company.id": "456",
"context.other.eventId": "big-conference1",
contextJson:
'{"company":{"id":"456"},"other":{"eventId":"big-conference1"},"user":{"id":"123"}}',
publishableKey: "pk",
});

expect(path).toEqual("/features/evaluated");
expect(timeoutMs).toEqual(5000);
});

test("sends array context as canonical JSON", async () => {
const { newFlagsClient, httpClient } = flagsClientFactory();
const flagsClient = newFlagsClient({
user: { id: "123", roles: ["admin", "editor"] },
other: { z: { second: 2, first: 1 }, a: true },
});

await flagsClient.initialize();

const { params } = vi.mocked(httpClient.get).mock.calls[0][0];
expect(new URLSearchParams(params).get("contextJson")).toBe(
'{"company":{"id":"456"},"other":{"a":true,"z":{"first":1,"second":2}},"user":{"id":"123","roles":["admin","editor"]}}',
);
});

test("uses waitForVersion when refreshing flags for a pushed version", async () => {
const { newFlagsClient, httpClient } = flagsClientFactory();
const flagsClient = newFlagsClient();
Expand All @@ -189,9 +203,8 @@ describe("FlagsClient", () => {

expect(path).toEqual("/features/evaluated");
expect(paramsObj).toMatchObject({
"context.user.id": "123",
"context.company.id": "456",
"context.other.eventId": "big-conference1",
contextJson:
'{"company":{"id":"456"},"other":{"eventId":"big-conference1"},"user":{"id":"123"}}',
publishableKey: "pk",
waitForVersion: "22",
});
Expand Down
12 changes: 8 additions & 4 deletions packages/node-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
} from "./types";
import {
applyLogLevel,
canonicalContextJSONStringify,
decorateLogger,
hashObject,
hashString,
Expand Down Expand Up @@ -1679,16 +1680,19 @@ export class ReflagClient {

checkContextWithTracking(contextWithTracking);

const params = new URLSearchParams(
Object.keys(context).length ? flattenJSON({ context }) : undefined,
);
const params = new URLSearchParams();
const contextJson = canonicalContextJSONStringify(context);
if (contextJson) {
params.set("contextJson", contextJson);
}
Comment thread
roncohen marked this conversation as resolved.

if (key) {
params.append("key", key);
}

const query = params.toString();
const res = await this.get<EvaluatedFlagsAPIResponse>(
`features/evaluated?${params}`,
`features/evaluated${query ? `?${query}` : ""}`,
);

if (key) {
Expand Down
Loading
Loading