From b7645d7247dd3fccb3718f596391d60f642236d2 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 6 Feb 2025 20:32:40 +0100 Subject: [PATCH 1/2] feat: new format for local-first development --- .../browser-sdk/example/typescript/app.ts | 18 +++-- .../example/typescript/bucket.config.ts | 7 ++ .../browser-sdk/example/typescript/index.html | 2 +- packages/browser-sdk/src/client.ts | 69 ++++++++++--------- .../src/{config.ts => constants.ts} | 0 packages/browser-sdk/src/feature/features.ts | 2 +- .../browser-sdk/src/featureDefinitions.ts | 41 +++++++++++ packages/browser-sdk/src/feedback/feedback.ts | 4 +- packages/browser-sdk/src/httpClient.ts | 2 +- packages/browser-sdk/src/index.ts | 6 ++ packages/browser-sdk/src/toolbar/index.ts | 2 +- .../test/e2e/acceptance.browser.spec.ts | 2 +- packages/browser-sdk/test/features.test.ts | 4 +- packages/browser-sdk/test/usage.test.ts | 2 +- packages/react-sdk/dev/plain/app.tsx | 35 +++++----- packages/react-sdk/dev/plain/bucket.d.ts | 6 ++ .../react-sdk/dev/plain/bucket.features.ts | 10 +++ packages/react-sdk/src/index.tsx | 15 +++- 18 files changed, 159 insertions(+), 68 deletions(-) create mode 100644 packages/browser-sdk/example/typescript/bucket.config.ts rename packages/browser-sdk/src/{config.ts => constants.ts} (100%) create mode 100644 packages/browser-sdk/src/featureDefinitions.ts create mode 100644 packages/react-sdk/dev/plain/bucket.d.ts create mode 100644 packages/react-sdk/dev/plain/bucket.features.ts diff --git a/packages/browser-sdk/example/typescript/app.ts b/packages/browser-sdk/example/typescript/app.ts index 72ee20cd..b06546ab 100644 --- a/packages/browser-sdk/example/typescript/app.ts +++ b/packages/browser-sdk/example/typescript/app.ts @@ -1,10 +1,11 @@ -import { BucketClient } from "../../src"; +import { BucketClient, FeatureKey } from "../../src"; +import features from "./bucket.config"; const urlParams = new URLSearchParams(window.location.search); const publishableKey = urlParams.get("publishableKey"); -const featureKey = urlParams.get("featureKey") ?? "huddles"; - -const featureList = ["huddles"]; +const featureKey = (urlParams.get("featureKey") ?? "huddles") as FeatureKey< + typeof features +>; if (!publishableKey) { throw Error("publishableKey is missing"); @@ -18,7 +19,7 @@ const bucket = new BucketClient({ show: true, position: { placement: "bottom-right" }, }, - featureList, + features, }); document @@ -38,9 +39,14 @@ bucket.initialize().then(() => { }); bucket.onFeaturesUpdated(() => { - const { isEnabled } = bucket.getFeature("huddles"); + const { isEnabled, config } = bucket.getFeature("huddles"); const startHuddleElem = document.getElementById("start-huddle"); + if (startHuddleElem) { + startHuddleElem.innerText = + config?.payload?.startHuddleCopy ?? "Start Huddle"; + } + if (isEnabled) { // show the start-huddle button if (startHuddleElem) startHuddleElem.style.display = "block"; diff --git a/packages/browser-sdk/example/typescript/bucket.config.ts b/packages/browser-sdk/example/typescript/bucket.config.ts new file mode 100644 index 00000000..515d7ba2 --- /dev/null +++ b/packages/browser-sdk/example/typescript/bucket.config.ts @@ -0,0 +1,7 @@ +import { defineFeatures } from "../../src"; + +export default defineFeatures([ + "feature1", + "feature2", + { key: "huddles", config: { startHuddleCopy: "string" } }, +]); diff --git a/packages/browser-sdk/example/typescript/index.html b/packages/browser-sdk/example/typescript/index.html index cec72d21..ec4f2fc7 100644 --- a/packages/browser-sdk/example/typescript/index.html +++ b/packages/browser-sdk/example/typescript/index.html @@ -14,7 +14,7 @@ } diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index eb7488f6..91c1b458 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -15,8 +15,13 @@ import { } from "./feedback/feedback"; import * as feedbackLib from "./feedback/ui"; import { ToolbarPosition } from "./toolbar/Toolbar"; -import { API_BASE_URL, APP_BASE_URL, SSE_REALTIME_BASE_URL } from "./config"; +import { API_BASE_URL, APP_BASE_URL, SSE_REALTIME_BASE_URL } from "./constants"; import { BucketContext, CompanyContext, UserContext } from "./context"; +import { + ConfigType, + FeatureDefinitions, + FeatureKey, +} from "./featureDefinitions"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; import { showToolbarToggle } from "./toolbar"; @@ -177,15 +182,10 @@ export type ToolbarOptions = position?: ToolbarPosition; }; -/** - * Feature definitions. - */ -export type FeatureDefinitions = Readonly>; - /** * BucketClient initialization options. */ -export type InitOptions = { +export type InitOptions = { /** * Publishable key for authentication */ @@ -284,7 +284,7 @@ export type InitOptions = { /** * Local-first definition of features */ - features?: FeatureDefinitions; + features?: TFeatures; }; const defaultConfig: Config = { @@ -294,27 +294,10 @@ const defaultConfig: Config = { enableTracking: true, }; -/** - * A remotely managed configuration value for a feature. - */ -export type FeatureRemoteConfig = - | { - /** - * The key of the matched configuration value. - */ - key: string; - - /** - * The optional user-supplied payload data. - */ - payload: any; - } - | { key: undefined; payload: undefined }; - /** * A feature. */ -export interface Feature { +export interface Feature { /** * Result of feature flag evaluation. */ @@ -323,7 +306,19 @@ export interface Feature { /* * Optional user-defined configuration. */ - config: FeatureRemoteConfig; + config: + | { + /** + * The key of the matched configuration value. + */ + key: string; + + /** + * The optional user-supplied payload data. + */ + payload: TRemoteConfig; + } + | { key: undefined; payload: undefined }; /** * Function to send analytics events for this feature. @@ -338,7 +333,7 @@ export interface Feature { ) => void; } -function shouldShowToolbar(opts: InitOptions) { +function shouldShowToolbar(opts: InitOptions) { const toolbarOpts = opts.toolbar; if (typeof toolbarOpts === "boolean") return toolbarOpts; if (typeof toolbarOpts?.show === "boolean") return toolbarOpts.show; @@ -351,7 +346,10 @@ function shouldShowToolbar(opts: InitOptions) { /** * BucketClient lets you interact with the Bucket API. */ -export class BucketClient { +export class BucketClient< + TFeatures extends FeatureDefinitions = any, + TFeatureKey extends string = FeatureKey, +> { private readonly publishableKey: string; private readonly context: BucketContext; private config: Config; @@ -367,7 +365,7 @@ export class BucketClient { /** * Create a new BucketClient instance. */ - constructor(opts: InitOptions) { + constructor(opts: InitOptions) { this.publishableKey = opts.publishableKey; this.logger = opts?.logger ?? loggerWithPrefix(quietConsoleLogger, "[Bucket]"); @@ -396,6 +394,9 @@ export class BucketClient { sdkVersion: opts?.sdkVersion, }); + const keys = + opts.features?.map((f) => (f instanceof Object ? f.key : f)) ?? []; + this.featuresClient = new FeaturesClient( this.httpClient, // API expects `other` and we have `otherContext`. @@ -404,7 +405,7 @@ export class BucketClient { company: this.context.company, other: this.context.otherContext, }, - opts?.features || [], + keys, this.logger, { expireTimeMs: opts.expireTimeMs, @@ -614,7 +615,7 @@ export class BucketClient { * * @param options */ - requestFeedback(options: RequestFeedbackData) { + requestFeedback(options: RequestFeedbackData>) { if (!this.context.user?.id) { this.logger.error( "`requestFeedback` call ignored. No `user` context provided at initialization", @@ -692,7 +693,9 @@ export class BucketClient { * Return a feature. Accessing `isEnabled` or `config` will automatically send a `check` event. * @returns A feature. */ - getFeature(key: string): Feature { + getFeature( + key: TKey, + ): Feature> { const f = this.getFeatures()[key]; const fClient = this.featuresClient; diff --git a/packages/browser-sdk/src/config.ts b/packages/browser-sdk/src/constants.ts similarity index 100% rename from packages/browser-sdk/src/config.ts rename to packages/browser-sdk/src/constants.ts diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index cd536e5d..07da92e4 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -1,4 +1,4 @@ -import { FEATURE_EVENTS_PER_MIN } from "../config"; +import { FEATURE_EVENTS_PER_MIN } from "../constants"; import { HttpClient } from "../httpClient"; import { Logger, loggerWithPrefix } from "../logger"; import RateLimiter from "../rateLimiter"; diff --git a/packages/browser-sdk/src/featureDefinitions.ts b/packages/browser-sdk/src/featureDefinitions.ts new file mode 100644 index 00000000..2d2d97de --- /dev/null +++ b/packages/browser-sdk/src/featureDefinitions.ts @@ -0,0 +1,41 @@ +export type ConfigDataType = + | "string" + | "number" + | "boolean" + | { [key: string]: ConfigDataType }; + +export type FeatureDefinitions = Readonly< + Array +>; + +export type StringTypeToTSType = C extends "string" + ? string + : C extends "number" + ? number + : C extends "boolean" + ? boolean + : C extends { [key: string]: ConfigDataType } + ? { [K in keyof C]: StringTypeToTSType } + : never; + +export type ConfigType = + Extract extends never + ? undefined + : StringTypeToTSType< + Extract["config"] + >; + +export type FeatureKey = + | Extract + | Extract["key"]; + +/** + * Define features for the SDK + * @param features Feature definitions + * @returns Feature definitions, ready to plug into the SDK + */ +export function defineFeatures( + features: TFeatures, +): TFeatures { + return features; +} diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index 3e70bd74..dec3c2df 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -69,7 +69,7 @@ export function handleDeprecatedFeedbackOptions( }; } -export type RequestFeedbackData = Omit< +export type RequestFeedbackData = Omit< OpenFeedbackFormOptions, "key" | "onSubmit" > & { @@ -93,7 +93,7 @@ export type RequestFeedbackData = Omit< /** * Bucket feature key. */ - featureKey: string; + featureKey: TFeatureKey; }; export type RequestFeedbackOptions = RequestFeedbackData & { diff --git a/packages/browser-sdk/src/httpClient.ts b/packages/browser-sdk/src/httpClient.ts index 43c925f8..ce91b596 100644 --- a/packages/browser-sdk/src/httpClient.ts +++ b/packages/browser-sdk/src/httpClient.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL, SDK_VERSION, SDK_VERSION_HEADER_NAME } from "./config"; +import { API_BASE_URL, SDK_VERSION, SDK_VERSION_HEADER_NAME } from "./constants"; export interface HttpClientOptions { baseUrl?: string; diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index a1044228..499bf1b4 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -7,6 +7,12 @@ export type { RawFeature, RawFeatures, } from "./feature/features"; +export type { + ConfigType, + FeatureDefinitions, + FeatureKey, +} from "./featureDefinitions"; +export { defineFeatures } from "./featureDefinitions"; export type { Feedback, FeedbackOptions, diff --git a/packages/browser-sdk/src/toolbar/index.ts b/packages/browser-sdk/src/toolbar/index.ts index 51d43d20..8c791292 100644 --- a/packages/browser-sdk/src/toolbar/index.ts +++ b/packages/browser-sdk/src/toolbar/index.ts @@ -7,7 +7,7 @@ import { attachContainer } from "../ui/utils"; import Toolbar, { ToolbarPosition } from "./Toolbar"; type showToolbarToggleOptions = { - bucketClient: BucketClient; + bucketClient: BucketClient; position?: ToolbarPosition; }; diff --git a/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts b/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts index 1fc5e8c6..41cedcbf 100644 --- a/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts +++ b/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts @@ -1,7 +1,7 @@ import { randomUUID } from "crypto"; import { expect, test } from "@playwright/test"; -import { API_BASE_URL } from "../../src/config"; +import { API_BASE_URL } from "../../src/constants"; const KEY = randomUUID(); diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index b0282fe0..d8360e64 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -1,13 +1,13 @@ import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; import { version } from "../package.json"; -import { FeatureDefinitions } from "../src/client"; import { FEATURES_EXPIRE_MS, FeaturesClient, FetchedFeature, RawFeature, } from "../src/feature/features"; +import { FeatureDefinitions } from "../src/featureDefinitions"; import { HttpClient } from "../src/httpClient"; import { featuresResult } from "./mocks/handlers"; @@ -48,7 +48,7 @@ function featuresClientFactory() { other: { eventId: "big-conference1" }, ...context, }, - features || [], + features?.map((f) => (f instanceof Object ? f.key : f)) || [], testLogger, { cache, diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index b792fdb2..3ffbc293 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -11,7 +11,7 @@ import { } from "vitest"; import { BucketClient } from "../src"; -import { API_BASE_URL } from "../src/config"; +import { API_BASE_URL } from "../src/constants"; import { FeaturesClient } from "../src/feature/features"; import { FeedbackPromptHandler } from "../src/feedback/feedback"; import { diff --git a/packages/react-sdk/dev/plain/app.tsx b/packages/react-sdk/dev/plain/app.tsx index 3890fa63..7ce5a81b 100644 --- a/packages/react-sdk/dev/plain/app.tsx +++ b/packages/react-sdk/dev/plain/app.tsx @@ -10,20 +10,14 @@ import { useUpdateOtherContext, useUpdateUser, } from "../../src"; - -// Extending the Features interface to define the available features -declare module "../../src" { - interface Features { - huddles: boolean; - } -} +import bucketFeatures from "./bucket.features"; const publishableKey = import.meta.env.VITE_PUBLISHABLE_KEY || ""; const apiBaseUrl = import.meta.env.VITE_BUCKET_API_BASE_URL; function HuddleFeature() { - // Type safe feature const feature = useFeature("huddles"); + return (

Huddle feature

@@ -148,7 +142,7 @@ function Feedback() { onClick={(e) => requestFeedback({ title: "How do you like Huddles?", - featureKey: "huddle", + featureKey: "huddles", position: { type: "POPOVER", anchor: e.currentTarget as HTMLElement, @@ -164,20 +158,26 @@ function Feedback() { // App.tsx function Demos() { + const { isEnabled, config } = useFeature("optIn"); + return (

React SDK

-

Feature opt-in

-
- Create a huddle feature and set a rule:{" "} - optin-huddles IS TRUE. Hit the checkbox below to opt-in/out - of the feature. -
- - + {isEnabled && ( + <> +

Feature opt-in

+
+ Create a huddle feature and set a rule:{" "} + optin-huddles IS TRUE. Hit the checkbox below to + opt-in/out of the feature. + {config.payload?.additionalCopy} +
+ + + )} @@ -228,6 +228,7 @@ export function App() { user={initialUser} otherContext={initialOtherContext} apiBaseUrl={apiBaseUrl} + features={bucketFeatures} > {} diff --git a/packages/react-sdk/dev/plain/bucket.d.ts b/packages/react-sdk/dev/plain/bucket.d.ts new file mode 100644 index 00000000..6bffbc6c --- /dev/null +++ b/packages/react-sdk/dev/plain/bucket.d.ts @@ -0,0 +1,6 @@ +import { FeaturesType } from "../../src"; +import features from "./bucket.features"; + +declare module "../../src" { + interface Features extends FeaturesType {} +} diff --git a/packages/react-sdk/dev/plain/bucket.features.ts b/packages/react-sdk/dev/plain/bucket.features.ts new file mode 100644 index 00000000..9bc76e94 --- /dev/null +++ b/packages/react-sdk/dev/plain/bucket.features.ts @@ -0,0 +1,10 @@ +import { defineFeatures } from "../../src"; + +export default defineFeatures([ + "huddles", + "voiceMessages", + { + key: "optIn", + config: { additionalCopy: "string" }, + }, +] as const); diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index caad7eaa..df4b68a3 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -10,9 +10,13 @@ import React, { } from "react"; import canonicalJSON from "canonical-json"; +import type { FeatureDefinitions } from "@bucketco/browser-sdk"; import { BucketClient, BucketContext, + ConfigType, + defineFeatures, + FeatureKey as BaseFeatureKey, FeedbackOptions, RawFeatures, RequestFeedbackData, @@ -22,6 +26,12 @@ import { import { version } from "../package.json"; +export { defineFeatures, FeatureDefinitions }; + +export type FeaturesType = { + [K in BaseFeatureKey]: ConfigType; +}; + export interface Features {} const SDK_VERSION = `react-sdk/${version}`; @@ -65,7 +75,7 @@ export type BucketProps = BucketContext & { debug?: boolean; enableTracking?: boolean; - features?: Readonly; + features?: Readonly; fallbackFeatures?: FeatureKey[] | Record; @@ -306,7 +316,8 @@ export function useTrack() { */ export function useRequestFeedback() { const { client } = useContext(ProviderContext); - return (options: RequestFeedbackData) => client?.requestFeedback(options); + return (options: RequestFeedbackData) => + client?.requestFeedback(options); } /** From 2a83e4c865e52ca4ac0e2e9d3160fed0c273be4b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 7 Feb 2025 13:22:07 +0100 Subject: [PATCH 2/2] remove unused type param --- packages/browser-sdk/src/toolbar/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-sdk/src/toolbar/index.ts b/packages/browser-sdk/src/toolbar/index.ts index 8c791292..51d43d20 100644 --- a/packages/browser-sdk/src/toolbar/index.ts +++ b/packages/browser-sdk/src/toolbar/index.ts @@ -7,7 +7,7 @@ import { attachContainer } from "../ui/utils"; import Toolbar, { ToolbarPosition } from "./Toolbar"; type showToolbarToggleOptions = { - bucketClient: BucketClient; + bucketClient: BucketClient; position?: ToolbarPosition; };