diff --git a/.changeset/context-json-sdk-transport.md b/.changeset/context-json-sdk-transport.md new file mode 100644 index 00000000..81d7e148 --- /dev/null +++ b/.changeset/context-json-sdk-transport.md @@ -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. diff --git a/packages/browser-sdk/src/context.ts b/packages/browser-sdk/src/context.ts index e4e18f3f..dad447cf 100644 --- a/packages/browser-sdk/src/context.ts +++ b/packages/browser-sdk/src/context.ts @@ -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, +): Record { + 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. @@ -17,7 +107,7 @@ export interface CompanyContext { /** * Other company attributes */ - [key: string]: string | number | undefined; + [key: string]: ContextValue; } /** @@ -44,7 +134,7 @@ export interface UserContext { /** * Other user attributes */ - [key: string]: string | number | undefined; + [key: string]: ContextValue; } /** @@ -67,7 +157,7 @@ export interface ReflagContext { /** * Context which is not related to a user or a company. */ - other?: Record; + other?: Record; } /** @@ -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; + otherContext?: Record; } diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index dacdb099..1bdb207f 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -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"; @@ -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, + ); // publishableKey should be part of the cache key params.append("publishableKey", this.httpClient.publishableKey); diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 095fa823..34555b0e 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -10,6 +10,8 @@ export type { export { ReflagClient } from "./client"; export type { CompanyContext, + ContextValue, + DefinedContextValue, ReflagContext, ReflagDeprecatedContext, UserContext, diff --git a/packages/browser-sdk/src/sse.ts b/packages/browser-sdk/src/sse.ts index 3ce3e267..9a6c4e90 100644 --- a/packages/browser-sdk/src/sse.ts +++ b/packages/browser-sdk/src/sse.ts @@ -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 = { @@ -17,36 +17,6 @@ export type PubSubMessage = { [key: string]: any; }; -function withoutUndefinedValues( - attributes: Record | undefined, -) { - if (!attributes) return undefined; - - const cleaned: Record = {}; - 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> = {}; - 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; @@ -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); } diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 1c3a66e7..9b9467e1 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -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, @@ -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(); @@ -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; diff --git a/packages/browser-sdk/test/context.test.ts b/packages/browser-sdk/test/context.test.ts new file mode 100644 index 00000000..53918d63 --- /dev/null +++ b/packages/browser-sdk/test/context.test.ts @@ -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 = {}; + circular.self = circular; + expect(() => + canonicalContextJSONStringify({ other: circular } as any), + ).toThrow("value must be JSON serializable"); + }); +}); diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 3dde1393..37a23b94 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -158,9 +158,8 @@ 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", }); @@ -168,6 +167,21 @@ describe("FlagsClient", () => { 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(); @@ -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", }); diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 2f5eb205..293ee46d 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -60,6 +60,7 @@ import { } from "./types"; import { applyLogLevel, + canonicalContextJSONStringify, decorateLogger, hashObject, hashString, @@ -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); + } if (key) { params.append("key", key); } + const query = params.toString(); const res = await this.get( - `features/evaluated?${params}`, + `features/evaluated${query ? `?${query}` : ""}`, ); if (key) { diff --git a/packages/node-sdk/src/utils.ts b/packages/node-sdk/src/utils.ts index d934a364..820f3eda 100644 --- a/packages/node-sdk/src/utils.ts +++ b/packages/node-sdk/src/utils.ts @@ -148,6 +148,81 @@ function updateSha1Hash(hash: Hash, value: any) { } } +/** + * Serialize JSON with object keys sorted recursively. Array order is preserved. + */ +export 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 { + ok(false, "value must be JSON serializable"); + } + ok(serialized !== undefined, "value must be JSON serializable"); + return serialized; +} + +function pruneUndefinedObjectValues( + value: object, + ancestors: WeakSet, +): Record { + ok(!ancestors.has(value), "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: object | 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; +} + /** Hash an object using SHA1. * * @param obj - The object to hash. diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 9130478b..5c9207a0 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -3174,19 +3174,41 @@ describe("getFlagsRemote", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1", + `https://api.example.com/features/evaluated?${new URLSearchParams({ + contextJson: + '{"company":{"id":"u1"},"other":{"custom":"context","key":"value"},"user":{"id":"c1"}}', + })}`, expectedHeaders, API_TIMEOUT_MS, ); }); - it("should not try to append the context if it's empty", async () => { - await client.getFlagsRemote(); + it("sends array context as canonical JSON", async () => { + await client.getFlagsRemote(undefined, undefined, { + other: { + z: { second: 2, first: 1 }, + roles: ["admin", "editor"], + a: true, + }, + }); + + const requestUrl = new URL(httpClient.get.mock.calls[0][0]); + expect(requestUrl.searchParams.get("contextJson")).toBe( + '{"other":{"a":true,"roles":["admin","editor"],"z":{"first":1,"second":2}}}', + ); + expect(requestUrl.searchParams.has("context.other.roles.0")).toBe(false); + }); + + it.each([ + ["absent", undefined], + ["effectively empty", { other: { nested: { value: undefined } } }], + ])("does not append %s context", async (_case, additionalContext) => { + await client.getFlagsRemote(undefined, undefined, additionalContext); expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?", + "https://api.example.com/features/evaluated", expectedHeaders, API_TIMEOUT_MS, ); @@ -3246,7 +3268,11 @@ describe("getFlagRemote", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1&key=flag1", + `https://api.example.com/features/evaluated?${new URLSearchParams({ + contextJson: + '{"company":{"id":"u1"},"other":{"custom":"context","key":"value"},"user":{"id":"c1"}}', + key: "flag1", + })}`, expectedHeaders, API_TIMEOUT_MS, ); @@ -3566,7 +3592,10 @@ describe("BoundReflagClient", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value", + `https://api.example.com/features/evaluated?${new URLSearchParams({ + contextJson: + '{"company":{"employees":100,"id":"company123","name":"Acme Inc."},"other":{"custom":"context","key":"value"},"user":{"age":1,"id":"user123","name":"John"}}', + })}`, expectedHeaders, API_TIMEOUT_MS, ); @@ -3591,7 +3620,11 @@ describe("BoundReflagClient", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value&key=flag1", + `https://api.example.com/features/evaluated?${new URLSearchParams({ + contextJson: + '{"company":{"employees":100,"id":"company123","name":"Acme Inc."},"other":{"custom":"context","key":"value"},"user":{"age":1,"id":"user123","name":"John"}}', + key: "flag1", + })}`, expectedHeaders, API_TIMEOUT_MS, ); diff --git a/packages/node-sdk/test/utils.test.ts b/packages/node-sdk/test/utils.test.ts index 12fa1bf4..b0fb95d1 100644 --- a/packages/node-sdk/test/utils.test.ts +++ b/packages/node-sdk/test/utils.test.ts @@ -3,6 +3,8 @@ import { createHash } from "crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + canonicalContextJSONStringify, + canonicalJSONStringify, decorateLogger, hashObject, isObject, @@ -124,6 +126,52 @@ describe("mergeSkipUndefined", () => { }); }); +describe("canonicalJSONStringify", () => { + it("sorts object keys recursively while preserving array order", () => { + expect( + canonicalJSONStringify({ + z: { second: 2, first: 1 }, + values: [{ b: true, a: false }, "second"], + a: 1, + omitted: undefined, + }), + ).toBe( + '{"a":1,"values":[{"a":false,"b":true},"second"],"z":{"first":1,"second":2}}', + ); + }); + + it("normalizes bigint and reports circular values consistently", () => { + expect(canonicalJSONStringify({ value: 42n })).toBe('{"value":"42"}'); + + const circular: Record = {}; + circular.self = circular; + expect(() => canonicalJSONStringify(circular)).toThrow( + "validation failed: value must be JSON serializable", + ); + }); +}); + +describe("canonicalContextJSONStringify", () => { + it("deeply prunes undefined-only objects but preserves explicit empties", () => { + expect( + canonicalContextJSONStringify({ + user: { id: undefined }, + other: { + dropped: { value: undefined }, + explicitEmpty: {}, + values: [{ value: undefined }], + }, + }), + ).toBe('{"other":{"explicitEmpty":{},"values":[{}]}}'); + }); + + it("omits an effectively empty context", () => { + expect( + canonicalContextJSONStringify({ user: { id: undefined } }), + ).toBeUndefined(); + }); +}); + describe("hashObject", () => { it("should throw if the given value is not an object", () => { expect(() => hashObject(null as any)).toThrowError( diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 8272516d..b3dcbbc6 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -214,10 +214,29 @@ const reflagClientBootstrappedStates = new WeakMap< BrowserBootstrappedState >(); +function contextValueEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((value, index) => contextValueEqual(value, b[index])) + ); + } + if (!a || !b || typeof a !== "object" || typeof b !== "object") { + return false; + } + return contextPartEqual( + a as Record, + b as Record, + ); +} + function contextPartEqual( - a?: Record, - b?: Record, -) { + a?: Record, + b?: Record, +): boolean { if (a === b) return true; if (!a || !b) return !a && !b; @@ -226,7 +245,9 @@ function contextPartEqual( if (aKeys.length !== bKeys.length) return false; return aKeys.every( - (key) => Object.prototype.hasOwnProperty.call(b, key) && a[key] === b[key], + (key) => + Object.prototype.hasOwnProperty.call(b, key) && + contextValueEqual(a[key], b[key]), ); } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index b30a5f76..fe0ce835 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -313,6 +313,28 @@ describe("", () => { expect(ReflagClient.prototype.stop).not.toHaveBeenCalledOnce(); }); + test("does not reset context for structurally equal nested values", async () => { + const setContext = vi.spyOn(ReflagClient.prototype, "setContext"); + const provider = () => + getProvider({ + context: { + user: { + id: "nested-user", + roles: ["admin", "editor"], + settings: { notifications: { enabled: true } }, + }, + }, + }); + + const { rerender } = render(provider()); + await waitFor(() => expect(setContext).toHaveBeenCalledTimes(1)); + + rerender(provider()); + await act(async () => undefined); + + expect(setContext).toHaveBeenCalledTimes(1); + }); + test("handles context changes", async () => { const { queryByTestId, rerender } = render( getProvider({ @@ -702,9 +724,15 @@ describe("useUpdateUser", () => { server.use( http.get(/\/features\/evaluated$/, ({ request }) => { - const siteCentricOptIn = new URL(request.url).searchParams.get( - "context.user.siteCentricOptIn", + const contextJson = new URL(request.url).searchParams.get( + "contextJson", ); + const context = contextJson + ? (JSON.parse(contextJson) as { + user?: { siteCentricOptIn?: string }; + }) + : undefined; + const siteCentricOptIn = context?.user?.siteCentricOptIn ?? null; seenOptInValues.push(siteCentricOptIn); return HttpResponse.json({