From 0c6a15e6a14e69311c34fa774846d4d4f53582d2 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sat, 3 Aug 2024 13:30:14 +0200 Subject: [PATCH 1/6] live updates wip --- packages/browser-sdk/src/client.ts | 29 +++--- packages/browser-sdk/src/feedback/feedback.ts | 97 ++++--------------- .../browser-sdk/src/feedback/promptStorage.ts | 12 +-- packages/browser-sdk/src/flags/flags.ts | 89 +++++++++++++---- packages/browser-sdk/src/flags/flagsCache.ts | 37 ++++--- packages/browser-sdk/src/sse.ts | 97 +++++++++++++------ packages/browser-sdk/test/usage.test.ts | 8 +- packages/react-sdk/package.json | 4 +- packages/react-sdk/src/index.tsx | 3 + 9 files changed, 217 insertions(+), 159 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 4cfe3fb7..818be473 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -11,6 +11,7 @@ import { API_HOST, SSE_REALTIME_HOST } from "./config"; import { BucketContext } from "./context"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; +import { AblySSEConn } from "./sse"; const isMobile = typeof window !== "undefined" && window.innerWidth < 768; @@ -72,8 +73,8 @@ export class BucketClient { private logger: Logger; private httpClient: HttpClient; - private liveSatisfaction: LiveSatisfaction | undefined; private flagsClient: FlagsClient; + private sseConn: AblySSEConn; constructor( publishableKey: string, @@ -100,11 +101,18 @@ export class BucketClient { sdkVersion: opts?.sdkVersion, }); + this.sseConn = new AblySSEConn( + this.context.user ? String(this.context.user.id) : "", + this.config.sseHost, + this.httpClient, + this.logger, + ); + this.flagsClient = new FlagsClient( this.httpClient, this.context, this.logger, - opts?.flags, + { ...opts?.flags, liveConn: this.sseConn }, ); if ( @@ -116,14 +124,15 @@ export class BucketClient { "Feedback prompting is not supported on mobile devices", ); } else { - this.liveSatisfaction = new LiveSatisfaction( - this.config.sseHost, + // initialize LiveSatisfaction + new LiveSatisfaction( this.logger, this.httpClient, opts?.feedback?.liveSatisfactionHandler, String(this.context.user?.id), opts?.feedback?.ui?.position, opts?.feedback?.ui?.translations, + this.sseConn, ); } } @@ -135,11 +144,9 @@ export class BucketClient { * Must be called before calling other SDK methods. */ async initialize() { - const inits = [this.flagsClient.initialize()]; - if (this.liveSatisfaction) { - inits.push(this.liveSatisfaction.initialize()); - } - await Promise.all(inits); + await this.flagsClient.maybeRefreshFlags(); + + this.sseConn.open(); this.logger.debug( `initialized with key "${this.publishableKey}" and options`, @@ -313,8 +320,8 @@ export class BucketClient { } stop() { - if (this.liveSatisfaction) { - this.liveSatisfaction.stop(); + if (this.sseConn) { + this.sseConn.close(); } } } diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index 4753ee8e..4a3ce7d1 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -1,6 +1,6 @@ import { HttpClient } from "../httpClient"; import { Logger } from "../logger"; -import { AblySSEChannel, openAblySSEChannel } from "../sse"; +import { AblySSEConn } from "../sse"; import { FeedbackPosition, @@ -13,7 +13,6 @@ import { parsePromptMessage, processPromptMessage, } from "./prompts"; -import { getAuthToken } from "./promptStorage"; import { DEFAULT_POSITION } from "./ui"; import * as feedbackLib from "./ui"; @@ -208,93 +207,44 @@ export async function feedback( } export class LiveSatisfaction { - private initialized = false; - private sseChannel: AblySSEChannel | null = null; - constructor( - private sseHost: string, private logger: Logger, private httpClient: HttpClient, private feedbackPromptHandler: FeedbackPromptHandler = createDefaultFeedbackPromptHandler(), private userId: string, private position: FeedbackPosition = DEFAULT_POSITION, private feedbackTranslations: Partial = {}, - ) {} - - /** - * Start receiving Live Satisfaction feedback prompts. - */ - async initialize() { - if (this.initialized) { - this.logger.error("feedback prompting already initialized"); - return; - } - - const channel = await this.getChannel(); - if (!channel) return; - - try { - this.logger.debug(`feedback prompting enabled`, channel); - this.sseChannel = openAblySSEChannel({ - userId: this.userId, - channel, - httpClient: this.httpClient, - callback: (message) => - this.handleFeedbackPromptRequest(this.userId, message), - logger: this.logger, - sseHost: this.sseHost, - }); - this.logger.debug(`feedback prompting connection established`); - } catch (e) { - this.logger.error(`error initializing feedback prompting`, e); - } - } - - private async getChannel() { - const existingAuth = getAuthToken(this.userId); - const channel = existingAuth?.channel; - - if (channel) { - return channel; - } - - try { - if (!channel) { - const res = await this.httpClient.post({ - path: `/feedback/prompting-init`, - body: { - userId: this.userId, - }, - }); - - this.logger.debug(`feedback prompting status sent`, res); - if (res.ok) { - const body: { success: boolean; channel?: string } = await res.json(); - if (body.success && body.channel) { - return body.channel; - } - } - } - } catch (e) { - this.logger.error(`error initializing feedback prompting`, e); - return; - } - return; + private sseChannel: AblySSEConn, + ) { + this.sseChannel.addOnMessageCallback( + "feedback_prompt", + this.handleFeedbackPromptRequest, + ); } - handleFeedbackPromptRequest(userId: string, message: any) { + // /** + // * Start receiving Live Satisfaction feedback prompts. + // */ + // async initialize() { + // if (this.initialized) { + // this.logger.error("feedback prompting already initialized"); + // return; + // } + // } + + handleFeedbackPromptRequest(message: any) { const parsed = parsePromptMessage(message); if (!parsed) { this.logger.error(`invalid feedback prompt message received`, message); } else { if ( - !processPromptMessage(userId, parsed, async (u, m, cb) => { + !processPromptMessage(this.userId, parsed, async (u, m, cb) => { await this.feedbackPromptEvent({ promptId: parsed.promptId, featureId: parsed.featureId, promptedQuestion: parsed.question, event: "received", - userId, + userId: this.userId, }); await this.triggerFeedbackPrompt(u, m, cb); }) @@ -307,13 +257,6 @@ export class LiveSatisfaction { } } - stop() { - if (this.sseChannel) { - this.sseChannel.close(); - this.sseChannel = null; - } - } - async triggerFeedbackPrompt( userId: string, message: FeedbackPrompt, diff --git a/packages/browser-sdk/src/feedback/promptStorage.ts b/packages/browser-sdk/src/feedback/promptStorage.ts index bea0ed9d..7345c739 100644 --- a/packages/browser-sdk/src/feedback/promptStorage.ts +++ b/packages/browser-sdk/src/feedback/promptStorage.ts @@ -22,11 +22,11 @@ export const checkPromptMessageCompleted = ( export const rememberAuthToken = ( userId: string, - channel: string, + channels: string[], token: string, expiresAt: Date, ) => { - Cookies.set(`bucket-token-${userId}`, JSON.stringify({ channel, token }), { + Cookies.set(`bucket-token-${userId}`, JSON.stringify({ channels, token }), { expires: expiresAt, sameSite: "strict", secure: true, @@ -40,15 +40,15 @@ export const getAuthToken = (userId: string) => { } try { - const { channel, token } = JSON.parse(val) as { - channel: string; + const { channels, token } = JSON.parse(val) as { + channels: string[]; token: string; }; - if (!channel?.length || !token?.length) { + if (!channels?.length || !token?.length) { return undefined; } return { - channel, + channels, token, }; } catch (e) { diff --git a/packages/browser-sdk/src/flags/flags.ts b/packages/browser-sdk/src/flags/flags.ts index 39177750..db4cb838 100644 --- a/packages/browser-sdk/src/flags/flags.ts +++ b/packages/browser-sdk/src/flags/flags.ts @@ -3,6 +3,7 @@ import { BucketContext } from "../context"; import { HttpClient } from "../httpClient"; import { Logger, loggerWithPrefix } from "../logger"; import RateLimiter from "../rateLimiter"; +import { AblySSEConn } from "../sse"; import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagsCache"; import maskedProxy from "./maskedProxy"; @@ -14,6 +15,10 @@ export type APIFlagResponse = { }; export type APIFlagsResponse = Record; +export type APIResponse = { + flags: APIFlagsResponse | undefined; + updatedAt: number; +}; export type Flags = Record; @@ -22,6 +27,7 @@ export type FlagsOptions = { timeoutMs?: number; staleWhileRevalidate?: boolean; failureRetryAttempts?: number | false; + onUpdatedFlags?: (flags: Flags) => void; }; type Config = { @@ -29,6 +35,7 @@ type Config = { timeoutMs: number; staleWhileRevalidate: boolean; failureRetryAttempts: number | false; + onUpdatedFlags?: (flags: Flags) => void; }; export const DEFAULT_FLAGS_CONFIG: Config = { @@ -70,17 +77,24 @@ export function validateFeatureFlagsResponse(response: any) { return; } - if (typeof response.success !== "boolean" || !isObject(response.flags)) { + const { success, updatedAt, flags } = response; + + if ( + typeof success !== "boolean" || + typeof updatedAt !== "number" || + !isObject(flags) + ) { return; } - const flags = parseAPIFlagsResponse(response.flags); - if (!flags) { + const flagsParsed = parseAPIFlagsResponse(flags); + if (!flagsParsed) { return; } return { - success: response.success, - flags, + success, + flags: flagsParsed, + updatedAt, }; } @@ -109,11 +123,13 @@ export const FLAGS_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000; // expire entirely after const localStorageCacheKey = `__bucket_flags`; export class FlagsClient { + private logger: Logger; private cache: FlagCache; - private flags: Flags | undefined; private config: Config; private rateLimiter: RateLimiter; - private logger: Logger; + + private flags: Flags | undefined; + private updatedAt: number; constructor( private httpClient: HttpClient, @@ -122,6 +138,8 @@ export class FlagsClient { options?: FlagsOptions & { cache?: FlagCache; rateLimiter?: RateLimiter; + liveConn?: AblySSEConn; + onUpdatedFlags?: (flags: Flags) => void; }, ) { this.logger = loggerWithPrefix(logger, "[Flags]"); @@ -138,21 +156,48 @@ export class FlagsClient { this.config = { ...DEFAULT_FLAGS_CONFIG, ...options }; this.rateLimiter = options?.rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger); + + // subscribe to changes in targeting + options?.liveConn?.addOnMessageCallback("targeting_updated", (message) => { + if (message.updatedAt && message.updatedAt > this.updatedAt) { + this.refreshFlags().catch((e) => { + this.logger.error( + "error refreshing flags following targeting-updated message", + e, + ); + }); + } + }); + + this.updatedAt = 0; } - async initialize() { - const flags = (await this.maybeFetchFlags()) || {}; - const proxiedFlags = maskedProxy(flags, (fs, key) => { + setFlags(flags: APIResponse) { + if (!flags.flags) return; + + const proxiedFlags = maskedProxy(flags.flags, (fs, key) => { this.sendCheckEvent({ key, - version: flags[key]?.version, - value: flags[key]?.value ?? false, + version: fs[key]?.version, + value: fs[key]?.value ?? false, }).catch((e) => { this.logger.error("error sending flag check event", e); }); return fs[key]?.value || false; }); this.flags = proxiedFlags; + + if(this.config.onUpdatedFlags) { + this.config.onUpdatedFlags(this.flags); + } + } + + public async maybeRefreshFlags() { + this.setFlags((await this.maybeFetchFlags()) || {}); + } + + public async refreshFlags() { + this.setFlags(await this.fetchFlags()); } getFlags(): Flags | undefined { @@ -171,7 +216,7 @@ export class FlagsClient { return params; } - private async maybeFetchFlags(): Promise { + private async maybeFetchFlags(): Promise { const cachedItem = this.cache.get(this.fetchParams().toString()); // if there's no cached item OR the cached item is a failure and we haven't retried @@ -193,17 +238,17 @@ export class FlagsClient { this.fetchFlags().catch(() => { // we don't care about the result, we just want to re-fetch }); - return cachedItem.flags; + return { flags: cachedItem.flags, updatedAt: cachedItem.updatedAt }; } return await this.fetchFlags(); } // serve cached items if not stale and not expired - return cachedItem.flags; + return { flags: cachedItem.flags, updatedAt: cachedItem.updatedAt }; } - public async fetchFlags(): Promise { + public async fetchFlags(): Promise { const params = this.fetchParams(); const cacheKey = params.toString(); try { @@ -233,10 +278,11 @@ export class FlagsClient { this.cache.set(cacheKey, { success: true, flags: typeRes.flags, + updatedAt: typeRes.updatedAt, attemptCount: 0, }); - return typeRes.flags; + return typeRes; } catch (e) { this.logger.error("error fetching flags: ", e); @@ -247,6 +293,7 @@ export class FlagsClient { success: current.success, flags: current.flags, attemptCount: current.attemptCount + 1, + updatedAt: current.updatedAt, }); } else { // otherwise cache if the request failed and there is no previous version to extend @@ -255,16 +302,22 @@ export class FlagsClient { success: false, flags: undefined, attemptCount: 1, + updatedAt: 0, }); } - return this.config.fallbackFlags.reduce((acc, key) => { + const flags = this.config.fallbackFlags.reduce((acc, key) => { acc[key] = { key, value: true, }; return acc; }, {} as APIFlagsResponse); + + return { + flags, + updatedAt: 0, + }; } } diff --git a/packages/browser-sdk/src/flags/flagsCache.ts b/packages/browser-sdk/src/flags/flagsCache.ts index fa956c4c..a3c9bcda 100644 --- a/packages/browser-sdk/src/flags/flagsCache.ts +++ b/packages/browser-sdk/src/flags/flagsCache.ts @@ -11,6 +11,7 @@ interface cacheEntry { success: boolean; // we also want to cache failures to avoid the UI waiting and spamming the API flags: APIFlagsResponse | undefined; attemptCount: number; + updatedAt: number; } // Parse and validate an API flag response @@ -45,6 +46,7 @@ export interface CacheResult { stale: boolean; success: boolean; attemptCount: number; + updatedAt: number; } export class FlagCache { @@ -72,7 +74,13 @@ export class FlagCache { success, flags, attemptCount, - }: { success: boolean; flags?: APIFlagsResponse; attemptCount: number }, + updatedAt, + }: { + success: boolean; + flags?: APIFlagsResponse; + attemptCount: number; + updatedAt: number; + }, ) { let cacheData: CacheData = {}; @@ -91,6 +99,7 @@ export class FlagCache { flags, success, attemptCount, + updatedAt, } satisfies cacheEntry; cacheData = Object.fromEntries( @@ -117,6 +126,7 @@ export class FlagCache { success: cachedResponse[key].success, stale: cachedResponse[key].staleAt < Date.now(), attemptCount: cachedResponse[key].attemptCount, + updatedAt: cachedResponse[key].updatedAt, }; } } @@ -138,22 +148,27 @@ function validateCacheData(cacheDataInput: any) { const cacheEntry = cacheDataInput[key]; if (!isObject(cacheEntry)) return; + const { expireAt, staleAt, success, flags, attemptCount, updatedAt } = + cacheEntry; + if ( - typeof cacheEntry.expireAt !== "number" || - typeof cacheEntry.staleAt !== "number" || - typeof cacheEntry.success !== "boolean" || - typeof cacheEntry.attemptCount !== "number" || - (cacheEntry.flags && !parseAPIFlagsResponse(cacheEntry.flags)) + typeof expireAt !== "number" || + typeof staleAt !== "number" || + typeof success !== "boolean" || + typeof attemptCount !== "number" || + typeof updatedAt !== "number" || + (flags && !parseAPIFlagsResponse(flags)) ) { return; } cacheData[key] = { - expireAt: cacheEntry.expireAt, - staleAt: cacheEntry.staleAt, - success: cacheEntry.success, - flags: cacheEntry.flags, - attemptCount: cacheEntry.attemptCount, + expireAt, + staleAt, + success, + flags, + attemptCount, + updatedAt, }; } return cacheData; diff --git a/packages/browser-sdk/src/sse.ts b/packages/browser-sdk/src/sse.ts index 59be589a..5bd74ede 100644 --- a/packages/browser-sdk/src/sse.ts +++ b/packages/browser-sdk/src/sse.ts @@ -18,27 +18,62 @@ interface AblyTokenRequest { const ABLY_TOKEN_ERROR_MIN = 40000; const ABLY_TOKEN_ERROR_MAX = 49999; -export class AblySSEChannel { +export class AblySSEConn { private isOpen: boolean = false; private eventSource: EventSource | null = null; private retryInterval: ReturnType | null = null; private logger: Logger; + private msgCallbacks: { [key: string]: (message: any) => void } = {}; + constructor( private userId: string, - private channel: string, private sseHost: string, - private messageHandler: (message: any) => void, private httpClient: HttpClient, logger: Logger, ) { this.logger = loggerWithPrefix(logger, "[SSE]"); } + private async initSse() { + const cached = getAuthToken(this.userId); + if (cached) { + this.logger.debug(`using existing token`, cached.channels, cached.token); + return cached.channels; + } + + try { + const res = await this.httpClient.post({ + path: `/sse-init`, + body: { + userId: this.userId, + }, + }); + + if (res.ok) { + const body: { success: boolean; channels?: string[] } = + await res.json(); + if (body.success && body.channels) { + this.logger.debug(`SSE channels fetched`); + return body.channels; + } + } + } catch (e) { + this.logger.error(`error initializing SSE`, e); + return; + } + return; + } + private async refreshTokenRequest() { + const channels = await this.initSse(); + if (!channels) { + return; + } + const params = new URLSearchParams({ userId: this.userId }); const res = await this.httpClient.get({ - path: `/feedback/prompting-auth`, + path: `/sse-auth`, params, }); @@ -49,7 +84,7 @@ export class AblySSEChannel { const tokenRequest: AblyTokenRequest = body; this.logger.debug("obtained new token request", tokenRequest); - return tokenRequest; + return { tokenRequest, channels }; } } @@ -57,11 +92,11 @@ export class AblySSEChannel { return; } - private async refreshToken() { + private async maybeRefreshToken() { const cached = getAuthToken(this.userId); - if (cached && cached.channel === this.channel) { - this.logger.debug("using existing token", cached.channel, cached.token); - return cached.token; + if (cached) { + this.logger.debug("using existing token", cached.channels, cached.token); + return cached; } const tokenRequest = await this.refreshTokenRequest(); @@ -70,7 +105,7 @@ export class AblySSEChannel { } const url = new URL( - `/keys/${encodeURIComponent(tokenRequest.keyName)}/requestToken`, + `/keys/${encodeURIComponent(tokenRequest.tokenRequest.keyName)}/requestToken`, this.sseHost, ); @@ -79,7 +114,7 @@ export class AblySSEChannel { headers: { "Content-Type": "application/json", }, - body: JSON.stringify(tokenRequest), + body: JSON.stringify(tokenRequest.tokenRequest), }); if (res.ok) { @@ -88,11 +123,11 @@ export class AblySSEChannel { rememberAuthToken( this.userId, - this.channel, + tokenRequest.channels, details.token, new Date(details.expires), ); - return details.token; + return { token: details.token, channels: tokenRequest.channels }; } this.logger.error("server did not release a token"); @@ -136,12 +171,14 @@ export class AblySSEChannel { private onMessage(e: MessageEvent) { let payload: any; + let eventName: string = ""; try { if (e.data) { const message = JSON.parse(e.data); - if (message.data) { + if (message.data && message.name) { payload = JSON.parse(message.data); + eventName = message.name; } } } catch (error: any) { @@ -153,7 +190,9 @@ export class AblySSEChannel { this.logger.debug("received message", payload); try { - this.messageHandler(payload); + if (eventName in this.msgCallbacks) { + this.msgCallbacks[eventName](payload); + } } catch (error: any) { this.logger.warn("failed to handle message", error, payload); } @@ -176,14 +215,14 @@ export class AblySSEChannel { this.isOpen = true; try { - const token = await this.refreshToken(); + const sseConfig = await this.maybeRefreshToken(); - if (!token) return; + if (!sseConfig) return; const url = new URL("/sse", this.sseHost); url.searchParams.append("v", "1.2"); - url.searchParams.append("accessToken", token); - url.searchParams.append("channels", this.channel); + url.searchParams.append("accessToken", sseConfig.token); + url.searchParams.append("channels", sseConfig.channels.join(",")); url.searchParams.append("rewind", "1"); this.eventSource = new EventSource(url); @@ -198,6 +237,13 @@ export class AblySSEChannel { } } + public addOnMessageCallback( + msgType: string, + callback: (message: any) => void, + ) { + this.msgCallbacks[msgType] = callback; + } + public disconnect() { if (!this.isOpen) { this.logger.warn("channel connection already closed"); @@ -271,8 +317,6 @@ export class AblySSEChannel { export function openAblySSEChannel({ userId, - channel, - callback, httpClient, sseHost, logger, @@ -284,20 +328,13 @@ export function openAblySSEChannel({ logger: Logger; sseHost: string; }) { - const sse = new AblySSEChannel( - userId, - channel, - sseHost, - callback, - httpClient, - logger, - ); + const sse = new AblySSEConn(userId, sseHost, httpClient, logger); sse.open(); return sse; } -export function closeAblySSEChannel(channel: AblySSEChannel) { +export function closeAblySSEChannel(channel: AblySSEConn) { channel.close(); } diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 6f80363e..fc0f83c8 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -18,7 +18,7 @@ import { markPromptMessageCompleted, } from "../src/feedback/promptStorage"; import { - AblySSEChannel, + AblySSEConn, closeAblySSEChannel, openAblySSEChannel, } from "../src/sse"; @@ -78,7 +78,7 @@ describe("feedback prompting", () => { beforeAll(() => { vi.mocked(openAblySSEChannel).mockReturnValue({ close: closeChannel, - } as unknown as AblySSEChannel); + } as unknown as AblySSEConn); vi.mocked(closeAblySSEChannel).mockResolvedValue(undefined); }); @@ -103,7 +103,7 @@ describe("feedback prompting", () => { test("does not call tracking endpoints if token cached", async () => { const specialChannel = "special-channel"; vi.mocked(getAuthToken).mockReturnValue({ - channel: specialChannel, + channels: [specialChannel], token: "something", }); @@ -169,7 +169,7 @@ describe("feedback state management", () => { beforeEach(() => { vi.mocked(openAblySSEChannel).mockImplementation(({ callback }) => { callback(message); - return {} as AblySSEChannel; + return {} as AblySSEConn; }); events = []; server.use( diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 31cea1aa..357bd6f8 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -32,8 +32,8 @@ "canonical-json": "^0.0.4" }, "peerDependencies": { - "react": "*", - "react-dom": "*" + "react": "^18.0", + "react-dom": "^18.0" }, "devDependencies": { "@bucketco/eslint-config": "0.0.2", diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index e488c0f8..7e2f6c4a 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -117,6 +117,9 @@ export function BucketProvider({ sseHost: config.sseHost, flags: { ...flagOptions, + onUpdatedFlags: (flags) => { + setFlags(flags); + }, }, feedback: config.feedback, logger: config.debug ? console : undefined, From 53bce7c777ee47d3c73aea86ecd97ca9c46df49c Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 9 Aug 2024 13:02:02 +0200 Subject: [PATCH 2/6] update yarn.lock --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index a923c0a8..c1e9f4e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1160,8 +1160,8 @@ __metadata: webpack: "npm:^5.89.0" webpack-cli: "npm:^5.1.4" peerDependencies: - react: "*" - react-dom: "*" + react: ^18.0 + react-dom: ^18.0 languageName: unknown linkType: soft From 05c4674500e92bd238bd715bd5d6090a56c7cce7 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 9 Aug 2024 13:28:30 +0200 Subject: [PATCH 3/6] merge fixups --- packages/browser-sdk/src/feature/features.ts | 34 ++++++++++++-------- packages/react-sdk/src/index.tsx | 4 +-- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 00a8b119..df1facfd 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -90,7 +90,7 @@ export function validateFeaturesResponse(response: any) { ) { return; } - const flagsParsed = parseAPIFeaturesResponse(flags); + const flagsParsed = parseAPIFeaturesResponse(features); if (!flagsParsed) { return; } @@ -159,7 +159,8 @@ export class FeaturesClient { }); this.config = { ...DEFAULT_FEATURES_CONFIG, ...options }; this.rateLimiter = - options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); + options?.rateLimiter ?? + new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); // subscribe to changes in targeting options?.liveConn?.addOnMessageCallback("targeting_updated", (message) => { @@ -176,13 +177,15 @@ export class FeaturesClient { this.updatedAt = 0; } - setFeatures(features: APIResponse) { - if (!features.flags) return; + private setFeatures(features: APIResponse) { + // ignore failures or older versions + if (!features || !features.features || features.updatedAt < this.updatedAt) + return; - const proxiedFlags = maskedProxy(features.flags, (fs, key) => { + const proxiedFlags = maskedProxy(features?.features || {}, (fs, key) => { this.sendCheckEvent({ key, - version: fs[key]?., + version: fs[key].version, value: fs[key]?.value ?? false, }).catch((e) => { this.logger.error("error sending feature check event", e); @@ -190,14 +193,15 @@ export class FeaturesClient { return fs[key]?.value || false; }); this.features = proxiedFlags; - - if(this.config.onUpdatedFlags) { + this.updatedAt = features.updatedAt; + + if (this.config.onUpdatedFlags) { this.config.onUpdatedFlags(this.features); } } public async maybeRefreshFeatures() { - this.setFeatures((await this.maybeFetchFeatures()) || {}); + this.setFeatures(await this.maybeFetchFeatures()); } public async refreshFeatures() { @@ -220,7 +224,7 @@ export class FeaturesClient { return params; } - private async maybeFetchFeatures(): Promise { + private async maybeFetchFeatures(): Promise { const cachedItem = this.cache.get(this.fetchParams().toString()); // if there's no cached item OR the cached item is a failure and we haven't retried @@ -242,7 +246,10 @@ export class FeaturesClient { this.fetchFeatures().catch(() => { // we don't care about the result, we just want to re-fetch }); - return { features: cachedItem.features, updatedAt: cachedItem.updatedAt }; + return { + features: cachedItem.features, + updatedAt: cachedItem.updatedAt, + }; } return await this.fetchFeatures(); @@ -319,12 +326,11 @@ export class FeaturesClient { value: true, }; return acc; - }, {} as APIFeaturesResponse); return { features, - updatedAt: 0 - } + updatedAt: 0, + }; } } diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index f7957124..a11d45b5 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -113,8 +113,8 @@ export function BucketProvider({ sseHost: config.sseHost, features: { ...featureOptions, - onUpdatedFeatures: (flags) => { - setFeatures(flags); + onUpdatedFeatures: (features) => { + setFeatures(features); }, }, feedback: config.feedback, From 18971383654baa45bbc53eebac8d594179992918 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 9 Aug 2024 13:33:42 +0200 Subject: [PATCH 4/6] various cleanups --- packages/browser-sdk/src/feature/features.ts | 30 +++++++++++--------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index df1facfd..d2159647 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -39,7 +39,6 @@ type Config = { timeoutMs: number; staleWhileRevalidate: boolean; failureRetryAttempts: number | false; - onUpdatedFlags?: (flags: Features) => void; }; export const DEFAULT_FEATURES_CONFIG: Config = { @@ -162,19 +161,24 @@ export class FeaturesClient { options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); - // subscribe to changes in targeting - options?.liveConn?.addOnMessageCallback("targeting_updated", (message) => { - if (message.updatedAt && message.updatedAt > this.updatedAt) { - this.refreshFeatures().catch((e) => { - this.logger.error( - "error refreshing flags following targeting-updated message", - e, - ); - }); - } - }); - this.updatedAt = 0; + + if (options?.onUpdatedFeatures) { + // subscribe to changes in targeting + options?.liveConn?.addOnMessageCallback( + "targeting_updated", + (message) => { + if (message.updatedAt && message.updatedAt > this.updatedAt) { + this.refreshFeatures().catch((e) => { + this.logger.error( + "error refreshing flags following targeting-updated message", + e, + ); + }); + } + }, + ); + } } private setFeatures(features: APIResponse) { From 7f6a1de4481d870fc5b3e10a3715683681da6187 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 12 Aug 2024 09:42:24 +0200 Subject: [PATCH 5/6] live targeting updates --- packages/browser-sdk/src/feature/features.ts | 10 +++++----- packages/react-sdk/src/index.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index d2159647..ec9706a4 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -31,7 +31,7 @@ export type FeaturesOptions = { timeoutMs?: number; staleWhileRevalidate?: boolean; failureRetryAttempts?: number | false; - onUpdatedFeatures?: (flags: Features) => void; + onUpdatedFeatures?: (features: Features) => void; }; type Config = { @@ -39,6 +39,7 @@ type Config = { timeoutMs: number; staleWhileRevalidate: boolean; failureRetryAttempts: number | false; + onUpdatedFeatures: (features: Features) => void; }; export const DEFAULT_FEATURES_CONFIG: Config = { @@ -46,6 +47,8 @@ export const DEFAULT_FEATURES_CONFIG: Config = { timeoutMs: 5000, staleWhileRevalidate: false, failureRetryAttempts: false, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onUpdatedFeatures: () => {}, }; // Deep merge two objects. @@ -142,7 +145,6 @@ export class FeaturesClient { cache?: FeatureCache; rateLimiter?: RateLimiter; liveConn?: AblySSEConn; - onUpdatedFeatures?: (flags: Features) => void; }, ) { this.logger = loggerWithPrefix(logger, "[Features]"); @@ -199,9 +201,7 @@ export class FeaturesClient { this.features = proxiedFlags; this.updatedAt = features.updatedAt; - if (this.config.onUpdatedFlags) { - this.config.onUpdatedFlags(this.features); - } + this.config.onUpdatedFeatures(this.features); } public async maybeRefreshFeatures() { diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index a11d45b5..203b6f67 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -113,8 +113,8 @@ export function BucketProvider({ sseHost: config.sseHost, features: { ...featureOptions, - onUpdatedFeatures: (features) => { - setFeatures(features); + onUpdatedFeatures: (updatedFeatures) => { + setFeatures(updatedFeatures); }, }, feedback: config.feedback, From 7ad6a50d7b5a20f7942b627b9ec626b2ab08f5da Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 12 Aug 2024 09:42:24 +0200 Subject: [PATCH 6/6] live targeting updates --- packages/browser-sdk/src/client.ts | 5 +++-- packages/browser-sdk/src/feature/features.ts | 10 +++++----- packages/browser-sdk/test/sse.test.ts | 4 ++-- packages/react-sdk/src/index.tsx | 11 ++++++++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index e73c1213..781bcb85 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -144,10 +144,11 @@ export class BucketClient { * Must be called before calling other SDK methods. */ async initialize() { - await this.featuresClient.maybeRefreshFeatures(); - + // start trying to open the SSE connection this.sseConn.open(); + await this.featuresClient.maybeRefreshFeatures(); + this.logger.debug( `initialized with key "${this.publishableKey}" and options`, this.config, diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index d2159647..ec9706a4 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -31,7 +31,7 @@ export type FeaturesOptions = { timeoutMs?: number; staleWhileRevalidate?: boolean; failureRetryAttempts?: number | false; - onUpdatedFeatures?: (flags: Features) => void; + onUpdatedFeatures?: (features: Features) => void; }; type Config = { @@ -39,6 +39,7 @@ type Config = { timeoutMs: number; staleWhileRevalidate: boolean; failureRetryAttempts: number | false; + onUpdatedFeatures: (features: Features) => void; }; export const DEFAULT_FEATURES_CONFIG: Config = { @@ -46,6 +47,8 @@ export const DEFAULT_FEATURES_CONFIG: Config = { timeoutMs: 5000, staleWhileRevalidate: false, failureRetryAttempts: false, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onUpdatedFeatures: () => {}, }; // Deep merge two objects. @@ -142,7 +145,6 @@ export class FeaturesClient { cache?: FeatureCache; rateLimiter?: RateLimiter; liveConn?: AblySSEConn; - onUpdatedFeatures?: (flags: Features) => void; }, ) { this.logger = loggerWithPrefix(logger, "[Features]"); @@ -199,9 +201,7 @@ export class FeaturesClient { this.features = proxiedFlags; this.updatedAt = features.updatedAt; - if (this.config.onUpdatedFlags) { - this.config.onUpdatedFlags(this.features); - } + this.config.onUpdatedFeatures(this.features); } public async maybeRefreshFeatures() { diff --git a/packages/browser-sdk/test/sse.test.ts b/packages/browser-sdk/test/sse.test.ts index 9fea9aeb..ede2b396 100644 --- a/packages/browser-sdk/test/sse.test.ts +++ b/packages/browser-sdk/test/sse.test.ts @@ -8,7 +8,7 @@ import { rememberAuthToken, } from "../src/feedback/promptStorage"; import { HttpClient } from "../src/httpClient"; -import { AblySSEChannel } from "../src/sse"; +import { AblySSEConn } from "../src/sse"; import { server } from "./mocks/server"; import { testLogger } from "./testLogger"; @@ -29,7 +29,7 @@ const channel = "channel"; function createSSEChannel(callback: (message: any) => void = vi.fn()) { const httpClient = new HttpClient(KEY, "https://front.bucket.co"); - const sse = new AblySSEChannel( + const sse = new AblySSEConn( userId, channel, sseHost, diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index a11d45b5..ba13403c 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -61,6 +61,7 @@ export type BucketProps = BucketContext & { publishableKey: string; featureOptions?: Omit & { fallbackFeatures?: BucketFeatures[]; + liveUpdate?: boolean; }; children?: ReactNode; loadingComponent?: ReactNode; @@ -108,14 +109,18 @@ export function BucketProvider({ clientRef.current.stop(); } + // on by unless set to false explicitly + const onUpdatedFeatures = + featureOptions?.liveUpdate !== false + ? (updatedFeatures: Features) => setFeatures(updatedFeatures) + : undefined; + const client = newBucketClient(publishableKey, featureContext, { host: config.host, sseHost: config.sseHost, features: { ...featureOptions, - onUpdatedFeatures: (features) => { - setFeatures(features); - }, + onUpdatedFeatures, }, feedback: config.feedback, logger: config.debug ? console : undefined,