diff --git a/.vscode/settings.json b/.vscode/settings.json index b1230452..4944fbf8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,7 +39,5 @@ "**/*.lock": true }, "typescript.tsdk": "node_modules/typescript/lib", - "cSpell.words": [ - "bucketco" - ] + "cSpell.words": ["bucketco", "openfeature"] } diff --git a/package.json b/package.json index b180bdc2..050c213f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "lerna run build --stream", "test:ci": "lerna run test:ci --stream", "test": "lerna run test --stream", + "format": "lerna run format --stream", "prettier": "lerna run prettier --stream", "prettier:fix": "lerna run prettier -- --write", "lint": "lerna run lint --stream", diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 63e88c60..eec910a2 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -27,19 +27,28 @@ const bucketClient = new BucketClient({ publishableKey, user, company }); await bucketClient.initialize(); -const { isEnabled, track, requestFeedback } = bucketClient.getFeature("huddle"); +const { + isEnabled, + config: { payload: question }, + track, + requestFeedback, +} = bucketClient.getFeature("huddle"); if (isEnabled) { - // show feature. When retrieving `isEnabled` the client automatically + // Show feature. When retrieving `isEnabled` the client automatically // sends a "check" event for the "huddle" feature which is shown in the // Bucket UI. // On usage, call `track` to let Bucket know that a user interacted with the feature track(); + // The `payload` is a user-supplied JSON in Bucket that is dynamically picked + // out depending on the user/company. + const question = payload?.question ?? "Tell us what you think of Huddles"; + // Use `requestFeedback` to create "Send feedback" buttons easily for specific // features. This is not related to `track` and you can call them individually. - requestFeedback({ title: "Tell us what you think of Huddles" }); + requestFeedback({ title: question }); } // `track` just calls `bucketClient.track()` to send an event using the same feature key @@ -138,6 +147,7 @@ To retrieve features along with their targeting information, use `getFeature(key const huddle = bucketClient.getFeature("huddle"); // { // isEnabled: true, +// config: { key: "zoom", payload: { ... } }, // track: () => Promise // requestFeedback: (options: RequestFeedbackData) => void // } @@ -151,6 +161,7 @@ const features = bucketClient.getFeatures(); // huddle: { // isEnabled: true, // targetingVersion: 42, +// config: ... // } // } ``` @@ -159,7 +170,35 @@ const features = bucketClient.getFeatures(); by down-stream clients, like the React SDK. Note that accessing `isEnabled` on the object returned by `getFeatures` does not automatically -generate a `check` event, contrary to the `isEnabled` property on the object return from `getFeature`. +generate a `check` event, contrary to the `isEnabled` property on the object returned by `getFeature`. + +### Remote config + +Similar to `isEnabled`, each feature has a `config` property. This configuration is managed from within Bucket. +It is managed similar to the way access to features is managed, but instead of the binary `isEnabled` you can have +multiple configuration values which are given to different user/companies. + +```ts +const features = bucketClient.getFeatures(); +// { +// huddle: { +// isEnabled: true, +// targetingVersion: 42, +// config: { +// key: "gpt-3.5", +// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } +// } +// } +// } +``` + +The `key` is always present while the `payload` is a optional JSON value for arbitrary configuration needs. +If feature has no configuration or, no configuration value was matched against the context, the `config` object +will be empty, thus, `key` will be `undefined`. Make sure to check against this case when trying to use the +configuration in your application. + +Just as `isEnabled`, accessing `config` on the object returned by `getFeatures` does not automatically +generate a `check` event, contrary to the `config` property on the object returned by `getFeature`. ### Tracking feature usage diff --git a/packages/browser-sdk/example/typescript/app.ts b/packages/browser-sdk/example/typescript/app.ts index 72ee20cd..77e6c116 100644 --- a/packages/browser-sdk/example/typescript/app.ts +++ b/packages/browser-sdk/example/typescript/app.ts @@ -2,15 +2,34 @@ import { BucketClient } from "../../src"; 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 keyof FeatureTypes; if (!publishableKey) { throw Error("publishableKey is missing"); } -const bucket = new BucketClient({ +interface FeatureTypes { + huddles: { + key: "huddles"; + isEnabled: boolean; + config: { + title: string; + description: string; + }; + }; + voiceHuddle: { + key: "voiceHuddle"; + isEnabled: boolean; + config: { + volumeMax: number; + }; + }; +} + +const featureList = ["huddles", "voiceHuddle"]; + +const bucket = new BucketClient({ publishableKey, user: { id: "42" }, company: { id: "1" }, diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index c32c6401..857bb156 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/browser-sdk", - "version": "3.0.0-alpha.0", + "version": "3.0.0-alpha.3", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { @@ -26,13 +26,18 @@ "files": [ "dist" ], - "main": "./dist/bucket-browser-sdk.umd.js", - "types": "./dist/types/src/index.d.ts", + "main": "./dist/bucket-browser-sdk-index.mjs", + "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./dist/bucket-browser-sdk.mjs", - "require": "./dist/bucket-browser-sdk.umd.js", - "types": "./dist/types/src/index.d.ts" + "import": "./dist/bucket-browser-sdk-index.mjs", + "require": "./dist/bucket-browser-sdk.amd.js", + "types": "./dist/index.d.ts" + }, + "./configuredClient": { + "import": "./dist/bucket-browser-sdk-configuredClient.mjs", + "require": "./dist/bucket-browser-sdk-configuredClient.amd.js", + "types": "./dist/configuredClient.d.ts" } }, "dependencies": { diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 24399556..7ba23e38 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -2,7 +2,7 @@ import { CheckEvent, FeaturesClient, FeaturesOptions, - RawFeatures, + RawFeature, } from "./feature/features"; import { AutoFeedback, @@ -180,7 +180,16 @@ export type ToolbarOptions = /** * Feature definitions. */ -export type FeatureDefinitions = Readonly>; +export type FeatureDefinitions = Readonly< + Array< + | string + | { + key: string; + access: boolean; + configType?: any; + } + > +>; /** * BucketClient initialization options. @@ -286,19 +295,50 @@ const defaultConfig: Config = { }; /** - * Represents a feature. + * A remotely managed configuration value for a feature. + */ +export type FeatureRemoteConfig< + TKey extends string, + FeatureDefs extends Record, +> = FeatureDefs[TKey] extends boolean ? never : FeatureDefs[TKey]["config"]; + +/** + * A feature. */ -export interface Feature { +export type Feature< + TKey extends string, + FeatureDefs extends Record, +> = { /** - * Result of feature flag evaluation + * Result of feature flag evaluation. */ + isEnabled: boolean; + /* + * Optional user-defined configuration. + */ + config: + | { + /** + * The key of the matched configuration value. + */ + key: string; + + /** + * The optional user-supplied payload data. + */ + payload: FeatureRemoteConfig; + } + | { + key: undefined; + payload: undefined; + }; + /** - * Function to send analytics events for this feature - * + * Function to send analytics events for this feature. */ - track: () => Promise; + track: () => Promise | undefined; /** * Function to request feedback for this feature. @@ -306,7 +346,7 @@ export interface Feature { requestFeedback: ( options: Omit, ) => void; -} +}; function shouldShowToolbar(opts: InitOptions) { const toolbarOpts = opts.toolbar; @@ -318,21 +358,38 @@ function shouldShowToolbar(opts: InitOptions) { ); } +/** + * Feature definition. Typically auto-generated by `@bucketco/cli`. + */ +export type FeatureDef = { + key: string; + access: boolean; + config: any; +}; + /** * BucketClient lets you interact with the Bucket API. + * + * @typeparam FeatureDef - The type of the feature definitions. If not set it defaults to `Record` which means the BucketClient doesn't have any type information about the features. This parameter is useful with code generated types from the `@bucketco/cli`. + * @typeparam FeatureKey - An internal type to represent the keys of the feature definitions. Automatically defaults to the keys of the feature definitions. + * */ -export class BucketClient { - private publishableKey: string; - private context: BucketContext; +export class BucketClient< + FeatureDefs extends Record = Record, + FeatureKey extends string = Extract, +> { + private readonly publishableKey: string; + private readonly context: BucketContext; private config: Config; private requestFeedbackOptions: Partial; - private httpClient: HttpClient; + private readonly httpClient: HttpClient; - private autoFeedback: AutoFeedback | undefined; + private readonly autoFeedback: AutoFeedback | undefined; private autoFeedbackInit: Promise | undefined; - private featuresClient: FeaturesClient; + private readonly featuresClient: FeaturesClient; public readonly logger: Logger; + /** * Create a new BucketClient instance. */ @@ -365,6 +422,10 @@ export class BucketClient { sdkVersion: opts?.sdkVersion, }); + const featuresList = (opts?.featureList || []).map((f) => + typeof f === "string" ? f : f.key, + ); + this.featuresClient = new FeaturesClient( this.httpClient, // API expects `other` and we have `otherContext`. @@ -373,9 +434,8 @@ export class BucketClient { company: this.context.company, other: this.context.otherContext, }, - opts?.featureList || [], + featuresList, this.logger, - opts?.features, ); if ( @@ -474,7 +534,7 @@ export class BucketClient { * Performs a shallow merge with the existing company context. * Attempting to update the company ID will log a warning and be ignored. * - * @param company + * @param company The company details. */ async updateCompany(company: { [key: string]: string | number | undefined }) { if (company.id && company.id !== this.context.company?.id) { @@ -496,6 +556,8 @@ export class BucketClient { * Update the company context. * Performs a shallow merge with the existing company context. * Updates to the company ID will be ignored. + * + * @param otherContext Additional context. */ async updateOtherContext(otherContext: { [key: string]: string | number | undefined; @@ -513,7 +575,7 @@ export class BucketClient { * * Calling `client.stop()` will remove all listeners added here. * - * @param cb this will be called when the features are updated. + * @param cb The callback to call when the update completes. */ onFeaturesUpdated(cb: () => void) { return this.featuresClient.onUpdated(cb); @@ -522,8 +584,8 @@ export class BucketClient { /** * Track an event in Bucket. * - * @param eventName The name of the event - * @param attributes Any attributes you want to attach to the event + * @param eventName The name of the event. + * @param attributes Any attributes you want to attach to the event. */ async track(eventName: string, attributes?: Record | null) { if (!this.context.user) { @@ -551,7 +613,8 @@ export class BucketClient { /** * Submit user feedback to Bucket. Must include either `score` or `comment`, or both. * - * @returns + * @param payload The feedback details to submit. + * @returns The server response. */ async feedback(payload: Feedback) { const userId = @@ -575,7 +638,11 @@ export class BucketClient { * * @param options */ - requestFeedback(options: RequestFeedbackData) { + requestFeedback( + options: Omit & { + featureKey: FeatureKey; + }, + ) { if (!this.context.user?.id) { this.logger.error( "`requestFeedback` call ignored. No `user` context provided at initialization", @@ -583,18 +650,16 @@ export class BucketClient { return; } - const featureId = "featureId" in options ? options.featureId : undefined; const featureKey = "featureKey" in options ? options.featureKey : undefined; - if (!featureId && !featureKey) { + if (!featureKey) { this.logger.error( - "`requestFeedback` call ignored. No `featureId` or `featureKey` provided", + "`requestFeedback` call ignored. No `featureKey` provided", ); return; } const feedbackData = { - featureId, featureKey, companyId: options.companyId || @@ -608,7 +673,7 @@ export class BucketClient { // to prevent the same click from closing it. setTimeout(() => { feedbackLib.openFeedbackForm({ - key: (featureKey || featureId)!, + key: featureKey, title: options.title, position: options.position || this.requestFeedbackOptions.position, translations: @@ -647,35 +712,49 @@ export class BucketClient { * and `isEnabled` does not take any feature overrides * into account. * - * @returns Map of features + * @returns Map of features. */ - getFeatures(): RawFeatures { - return this.featuresClient.getFeatures(); + getFeatures(): Record { + return this.featuresClient.getFeatures() as Record; } /** - * Return a feature. Accessing `isEnabled` will automatically send a `check` event. - * @returns A feature + * Return a feature. Accessing `isEnabled` or `config` will automatically send a `check` event. + * @returns A feature. */ - getFeature(key: string): Feature { + getFeature(key: FeatureKey): Feature { const f = this.getFeatures()[key]; const fClient = this.featuresClient; const value = f?.isEnabledOverride ?? f?.isEnabled ?? false; + const config = f?.config + ? { + key: f.config.key, + payload: f.config.payload, + } + : { key: undefined, payload: undefined }; + + function sendCheckEvent() { + fClient + .sendCheckEvent({ + key, + version: f?.targetingVersion, + value, + }) + .catch(() => { + // ignore + }); + } return { get isEnabled() { - fClient - .sendCheckEvent({ - key, - version: f?.targetingVersion, - value, - }) - .catch(() => { - // ignore - }); + sendCheckEvent(); return value; }, + get config() { + sendCheckEvent(); + return config; + }, track: () => this.track(key), requestFeedback: ( options: Omit, @@ -688,11 +767,11 @@ export class BucketClient { }; } - setFeatureOverride(key: string, isEnabled: boolean | null) { + setFeatureOverride(key: FeatureKey, isEnabled: boolean | null) { this.featuresClient.setFeatureOverride(key, isEnabled); } - getFeatureOverride(key: string): boolean | null { + getFeatureOverride(key: FeatureKey): boolean | null { return this.featuresClient.getFeatureOverride(key); } diff --git a/packages/browser-sdk/src/configuredClient.ts b/packages/browser-sdk/src/configuredClient.ts new file mode 100644 index 00000000..0494a1bc --- /dev/null +++ b/packages/browser-sdk/src/configuredClient.ts @@ -0,0 +1,24 @@ +// without the `type` keyword explicit here, the `type` keyword doesn't appear +// in the generated declaration file which breaks importing the generated +// feature type from "_bucket". +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - doesn't exist when building this package +import type { GeneratedFeatureTypes } from "_bucket"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - doesn't exist when building this package +// eslint-disable-next-line import/no-unresolved +import { generatedFeatures } from "_bucket"; + +import { BucketClient, InitOptions } from "./client"; + +export type FeatureKey = Extract; +export type FeatureDefs = GeneratedFeatureTypes; + +export class BucketClientConfigured extends BucketClient { + constructor(opts: InitOptions) { + super({ + featureList: generatedFeatures, + ...opts, + }); + } +} diff --git a/packages/browser-sdk/src/feature/featureCache.ts b/packages/browser-sdk/src/feature/featureCache.ts index 306aef97..b4cb8ed8 100644 --- a/packages/browser-sdk/src/feature/featureCache.ts +++ b/packages/browser-sdk/src/feature/featureCache.ts @@ -22,19 +22,24 @@ export function parseAPIFeaturesResponse( const features: FetchedFeatures = {}; for (const key in featuresInput) { const feature = featuresInput[key]; + if ( typeof feature.isEnabled !== "boolean" || feature.key !== key || - typeof feature.targetingVersion !== "number" + typeof feature.targetingVersion !== "number" || + (feature.config && typeof feature.config !== "object") ) { return; } + features[key] = { isEnabled: feature.isEnabled, targetingVersion: feature.targetingVersion, key, + config: feature.config, }; } + return features; } @@ -45,8 +50,8 @@ export interface CacheResult { export class FeatureCache { private storage: StorageItem; - private staleTimeMs: number; - private expireTimeMs: number; + private readonly staleTimeMs: number; + private readonly expireTimeMs: number; constructor({ storage, diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 32deebfd..7c11420a 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -9,6 +9,9 @@ import { parseAPIFeaturesResponse, } from "./featureCache"; +/** + * A feature fetched from the server. + */ export type FetchedFeature = { /** * Feature key @@ -24,11 +27,32 @@ export type FetchedFeature = { * Version of targeting rules */ targetingVersion?: number; + + /** + * Optional user-defined dynamic configuration. + */ + config?: { + /** + * The key of the matched configuration value. + */ + key: string; + + /** + * The version of the matched configuration value. + */ + version?: number; + + /** + * The optional user-supplied payload data. + */ + payload?: any; + }; }; const FEATURES_UPDATED_EVENT = "features-updated"; export type FetchedFeatures = Record; + // todo: on next major, come up with a better name for this type. Maybe `LocalFeature`. export type RawFeature = FetchedFeature & { /** @@ -36,14 +60,24 @@ export type RawFeature = FetchedFeature & { */ isEnabledOverride: boolean | null; }; + export type RawFeatures = Record; +export type FallbackFeatureOverride = + | { + key: string; + payload: any; + } + | true; + export type FeaturesOptions = { /** * Feature keys for which `isEnabled` should fallback to true - * if SDK fails to fetch features from Bucket servers. + * if SDK fails to fetch features from Bucket servers. If a record + * is supplied instead of array, the values of each key represent the + * configuration values and `isEnabled` is assume `true`. */ - fallbackFeatures?: string[]; + fallbackFeatures?: string[] | Record; /** * Timeout in milliseconds when fetching features @@ -67,30 +101,17 @@ export type FeaturesOptions = { }; type Config = { - fallbackFeatures: string[]; + fallbackFeatures: Record; timeoutMs: number; staleWhileRevalidate: boolean; }; export const DEFAULT_FEATURES_CONFIG: Config = { - fallbackFeatures: [], + fallbackFeatures: {}, timeoutMs: 5000, staleWhileRevalidate: false, }; -// Deep merge two objects. -export type FeaturesResponse = { - /** - * `true` if call was successful - */ - success: boolean; - - /** - * List of enabled features - */ - features: FetchedFeatures; -}; - export function validateFeaturesResponse(response: any) { if (!isObject(response)) { return; @@ -99,7 +120,9 @@ export function validateFeaturesResponse(response: any) { if (typeof response.success !== "boolean" || !isObject(response.features)) { return; } + const features = parseAPIFeaturesResponse(response.features); + if (!features) { return; } @@ -214,7 +237,23 @@ export class FeaturesClient { staleTimeMs: options?.staleTimeMs ?? 0, expireTimeMs: options?.expireTimeMs ?? FEATURES_EXPIRE_MS, }); - this.config = { ...DEFAULT_FEATURES_CONFIG, ...options }; + + let fallbackFeatures: Record; + + if (Array.isArray(options?.fallbackFeatures)) { + fallbackFeatures = options.fallbackFeatures.reduce( + (acc, key) => { + acc[key] = true; + return acc; + }, + {} as Record, + ); + } else { + fallbackFeatures = options?.fallbackFeatures ?? {}; + } + + this.config = { ...DEFAULT_FEATURES_CONFIG, ...options, fallbackFeatures }; + this.rateLimiter = options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); @@ -302,6 +341,7 @@ export class FeaturesClient { JSON.stringify(errorBody), ); } + const typeRes = validateFeaturesResponse(await res.json()); if (!typeRes || !typeRes.success) { throw new Error("unable to validate response"); @@ -438,13 +478,23 @@ export class FeaturesClient { } // fetch failed, nothing cached => return fallbacks - return this.config.fallbackFeatures.reduce((acc, key) => { - acc[key] = { - key, - isEnabled: true, - }; - return acc; - }, {} as FetchedFeatures); + return Object.entries(this.config.fallbackFeatures).reduce( + (acc, [key, override]) => { + acc[key] = { + key, + isEnabled: !!override, + config: + typeof override === "object" && "key" in override + ? { + key: override.key, + payload: override.payload, + } + : undefined, + }; + return acc; + }, + {} as FetchedFeatures, + ); } setFeatureOverride(key: string, isEnabled: boolean | null) { diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index c649b283..b031588f 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -69,22 +69,6 @@ export function handleDeprecatedFeedbackOptions( }; } -type FeatureIdentifier = - | { - /** - * Bucket feature ID. - * - * @deprecated use `feedbackId` instead. - */ - featureId: string; - } - | { - /** - * Bucket feature key. - */ - featureKey: string; - }; - export type RequestFeedbackData = Omit< OpenFeedbackFormOptions, "key" | "onSubmit" @@ -105,7 +89,12 @@ export type RequestFeedbackData = Omit< * @param data. */ onAfterSubmit?: (data: FeedbackSubmission) => void; -} & FeatureIdentifier; + + /** + * Bucket feature key. + */ + featureKey: string; +}; export type RequestFeedbackOptions = RequestFeedbackData & { /** @@ -115,6 +104,16 @@ export type RequestFeedbackOptions = RequestFeedbackData & { }; export type UnassignedFeedback = { + /** + * Feature key + **/ + featureKey?: string; + + /** + * Feature ID + **/ + featureId?: string; + /** * Bucket feedback ID */ @@ -159,7 +158,7 @@ export type UnassignedFeedback = { * - `sdk` - Feedback submitted via `feedback` */ source?: "prompt" | "sdk" | "widget"; -} & FeatureIdentifier; +}; export type Feedback = UnassignedFeedback & { /** diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 7b1186cb..081fc9d9 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -1,10 +1,15 @@ -// import "preact/debug"; - -export type { Feature, InitOptions, ToolbarOptions } from "./client"; +export type { + Feature, + FeatureDef, + FeatureRemoteConfig, + InitOptions, + ToolbarOptions, +} from "./client"; export { BucketClient } from "./client"; export type { BucketContext, CompanyContext, UserContext } from "./context"; export type { CheckEvent, + FallbackFeatureOverride, FeaturesOptions, RawFeature, RawFeatures, diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index b6bd728a..76cd1c24 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -68,7 +68,7 @@ describe("BucketClient", () => { describe("getFeature", () => { it("takes overrides into account", async () => { await client.initialize(); - expect(featuresResult.featureA.isEnabled).toBe(true); + expect(featuresResult["featureA"].isEnabled).toBe(true); expect(client.getFeature("featureA").isEnabled).toBe(true); client.setFeatureOverride("featureA", false); expect(client.getFeature("featureA").isEnabled).toBe(false); diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index 37be3245..0a2486ca 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -1,7 +1,6 @@ import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; import { version } from "../package.json"; -import { FeatureDefinitions } from "../src/client"; import { FEATURES_EXPIRE_MS, FeaturesClient, @@ -29,15 +28,17 @@ function featuresClientFactory() { const httpClient = new HttpClient("pk", { baseUrl: "https://front.bucket.co", }); + vi.spyOn(httpClient, "get"); vi.spyOn(httpClient, "post"); + return { cache, httpClient, newFeaturesClient: function newFeaturesClient( options?: FeaturesOptions, context?: any, - featureList: FeatureDefinitions = [], + featureList: string[] = [], ) { return new FeaturesClient( httpClient, @@ -58,7 +59,7 @@ function featuresClientFactory() { }; } -describe("FeaturesClient unit tests", () => { +describe("FeaturesClient", () => { test("fetches features", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); const featuresClient = newFeaturesClient(); @@ -73,8 +74,9 @@ describe("FeaturesClient unit tests", () => { expect(updated).toBe(true); expect(httpClient.get).toBeCalledTimes(1); - const calls = vi.mocked(httpClient.get).mock.calls.at(0); - const { params, path, timeoutMs } = calls![0]; + + const calls = vi.mocked(httpClient.get).mock.calls.at(0)!; + const { params, path, timeoutMs } = calls[0]; const paramsObj = Object.fromEntries(new URLSearchParams(params)); expect(paramsObj).toEqual({ @@ -116,21 +118,57 @@ describe("FeaturesClient unit tests", () => { expect(timeoutMs).toEqual(5000); }); - test("return fallback features on failure", async () => { + test("return fallback features on failure (string list)", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); vi.mocked(httpClient.get).mockRejectedValue( new Error("Failed to fetch features"), ); + const featuresClient = newFeaturesClient({ fallbackFeatures: ["huddle"], }); + await featuresClient.initialize(); - expect(featuresClient.getFeatures()).toEqual({ + expect(featuresClient.getFeatures()).toStrictEqual({ huddle: { isEnabled: true, + config: undefined, + key: "huddle", isEnabledOverride: null, + }, + }); + }); + + test("return fallback features on failure (record)", async () => { + const { newFeaturesClient, httpClient } = featuresClientFactory(); + + vi.mocked(httpClient.get).mockRejectedValue( + new Error("Failed to fetch features"), + ); + const featuresClient = newFeaturesClient({ + fallbackFeatures: { + huddle: { + key: "john", + payload: { something: "else" }, + }, + zoom: true, + }, + }); + + await featuresClient.initialize(); + expect(featuresClient.getFeatures()).toStrictEqual({ + huddle: { + isEnabled: true, + config: { key: "john", payload: { something: "else" } }, key: "huddle", + isEnabledOverride: null, + }, + zoom: { + isEnabled: true, + config: undefined, + key: "zoom", + isEnabledOverride: null, }, }); }); @@ -138,13 +176,14 @@ describe("FeaturesClient unit tests", () => { test("caches response", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); - const featuresClient = newFeaturesClient(); - await featuresClient.initialize(); + const featuresClient1 = newFeaturesClient(); + await featuresClient1.initialize(); expect(httpClient.get).toBeCalledTimes(1); const featuresClient2 = newFeaturesClient(); await featuresClient2.initialize(); + const features = featuresClient2.getFeatures(); expect(features).toEqual(featuresResult); @@ -314,15 +353,12 @@ describe("FeaturesClient unit tests", () => { updated = true; }); - expect(client.getFeatures().featureB.isEnabled).toBe(false); + expect(client.getFeatures().featureB.isEnabled).toBe(true); expect(client.getFeatures().featureB.isEnabledOverride).toBe(null); - expect(client.getFetchedFeatures()?.featureB).toBeUndefined(); - - client.setFeatureOverride("featureB", true); + client.setFeatureOverride("featureC", true); expect(updated).toBe(true); - expect(client.getFeatures().featureB.isEnabled).toBe(false); - expect(client.getFeatures().featureB.isEnabledOverride).toBe(true); + expect(client.getFeatures().featureC).toBeUndefined(); }); }); diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 21d6a3e8..62fd5fe1 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -1,25 +1,42 @@ import { DefaultBodyType, http, HttpResponse, StrictRequest } from "msw"; -import { Features } from "../../../node-sdk/src/types"; -import { FeaturesResponse } from "../../src/feature/features"; +import { RawFeatures } from "../../src/feature/features"; export const testChannel = "testChannel"; -export const featureResponse: FeaturesResponse = { +export const featureResponse = { success: true, features: { - featureA: { isEnabled: true, key: "featureA", targetingVersion: 1 }, + featureA: { + isEnabled: true, + key: "featureA", + targetingVersion: 1, + config: undefined, + }, + featureB: { + isEnabled: true, + targetingVersion: 11, + key: "featureB", + config: { + version: 12, + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + }, + }, }, }; -export const featuresResult = { - featureA: { - isEnabled: true, - key: "featureA", - targetingVersion: 1, - isEnabledOverride: null, +export const featuresResult = Object.entries(featureResponse.features).reduce( + (acc, [key, feature]) => { + acc[key] = { + ...feature!, + config: feature.config, + isEnabledOverride: null, + }; + return acc; }, -} satisfies Features; + {} as RawFeatures, +); function checkRequest(request: StrictRequest) { const url = new URL(request.url); @@ -104,6 +121,18 @@ export const handlers = [ success: true, }); }), + http.post("https://front.bucket.co/features/events", async ({ request }) => { + if (!checkRequest(request)) return invalidReqResponse; + const data = await request.json(); + + if (typeof data !== "object" || !data || !data["userId"]) { + return new HttpResponse(null, { status: 400 }); + } + + return HttpResponse.json({ + success: true, + }); + }), http.post("https://front.bucket.co/feedback", async ({ request }) => { if (!checkRequest(request)) return invalidReqResponse; const data = await request.json(); @@ -134,4 +163,19 @@ export const handlers = [ if (!checkRequest(request)) return invalidReqResponse; return HttpResponse.json({ success: true, keyName: "keyName" }); }), + http.post( + "https://livemessaging.bucket.co/keys/keyName/requestToken", + async ({ request }) => { + const data = await request.json(); + if (typeof data !== "object") { + return new HttpResponse(null, { status: 400 }); + } + + return HttpResponse.json({ + success: true, + token: "token", + expires: 1234567890, + }); + }, + ), ]; diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 905d27f8..31399087 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -75,10 +75,11 @@ describe("usage", () => { expect(features).toEqual(featuresResult); const featureId1 = bucketInstance.getFeature("featureId1"); - expect(featureId1).toEqual({ + expect(featureId1).toStrictEqual({ isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), + config: { key: undefined, payload: undefined }, }); }); @@ -393,91 +394,161 @@ describe(`sends "check" events `, () => { ).toHaveBeenCalledTimes(0); }); - it(`getFeature() sends check event when accessing "isEnabled"`, async () => { - vi.spyOn(FeaturesClient.prototype, "sendCheckEvent"); - vi.spyOn(HttpClient.prototype, "post"); - - const client = new BucketClient({ - publishableKey: KEY, - user: { id: "uid" }, - company: { id: "cid" }, + describe("getFeature", async () => { + afterEach(() => { + vi.clearAllMocks(); }); - await client.initialize(); - const featureA = client.getFeature("featureA"); + it(`returns get the expected feature details`, async () => { + const client = new BucketClient({ + publishableKey: KEY, + user: { id: "uid" }, + company: { id: "cid" }, + }); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledTimes(0); - expect(featureA.isEnabled).toBe(true); + await client.initialize(); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledTimes(1); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledWith({ - key: "featureA", - value: true, - version: 1, - }); + expect(client.getFeature("featureA")).toStrictEqual({ + isEnabled: true, + config: { key: undefined, payload: undefined }, + track: expect.any(Function), + requestFeedback: expect.any(Function), + }); - expect(vi.mocked(HttpClient.prototype.post)).toHaveBeenCalledWith({ - body: { - action: "check", - evalContext: { - company: { - id: "cid", - }, - other: undefined, - user: { - id: "uid", + expect(client.getFeature("featureB")).toStrictEqual({ + isEnabled: true, + config: { + key: "gpt3", + payload: { + model: "gpt-something", + temperature: 0.5, }, }, - evalResult: true, + track: expect.any(Function), + requestFeedback: expect.any(Function), + }); + + expect(client.getFeature("featureC")).toStrictEqual({ + isEnabled: false, + config: { key: undefined, payload: undefined }, + track: expect.any(Function), + requestFeedback: expect.any(Function), + }); + }); + + it(`sends check event when accessing "isEnabled"`, async () => { + const sendCheckEventSpy = vi.spyOn( + FeaturesClient.prototype, + "sendCheckEvent", + ); + const postSpy = vi.spyOn(HttpClient.prototype, "post"); + + const client = new BucketClient({ + publishableKey: KEY, + user: { id: "uid" }, + company: { id: "cid" }, + }); + await client.initialize(); + + const featureA = client.getFeature("featureA"); + + expect(sendCheckEventSpy).toHaveBeenCalledTimes(0); + expect(featureA.isEnabled).toBe(true); + + expect(sendCheckEventSpy).toHaveBeenCalledTimes(1); + expect(sendCheckEventSpy).toHaveBeenCalledWith({ key: "featureA", - targetingVersion: 1, - }, - path: "features/events", + value: true, + version: 1, + }); + + expect(postSpy).toHaveBeenCalledWith({ + body: { + action: "check", + evalContext: { + company: { + id: "cid", + }, + other: undefined, + user: { + id: "uid", + }, + }, + evalResult: true, + key: "featureA", + targetingVersion: 1, + }, + path: "features/events", + }); }); - }); - it("sends check event for not-enabled features", async () => { - // disabled features don't appear in the API response - vi.spyOn(FeaturesClient.prototype, "sendCheckEvent"); + it(`sends check event when accessing "config"`, async () => { + const postSpy = vi.spyOn(HttpClient.prototype, "post"); - const client = new BucketClient({ publishableKey: KEY }); - await client.initialize(); + const client = new BucketClient({ + publishableKey: KEY, + user: { id: "uid" }, + }); - const nonExistentFeature = client.getFeature("non-existent"); + await client.initialize(); + const featureB = client.getFeature("featureB"); + expect(featureB.config).toMatchObject({ + key: "gpt3", + }); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledTimes(0); - expect(nonExistentFeature.isEnabled).toBe(false); + expect(postSpy).toHaveBeenCalledWith({ + body: { + action: "check", + evalContext: { + other: undefined, + user: { + id: "uid", + }, + }, + evalResult: true, + key: "featureB", + targetingVersion: 11, + }, + path: "features/events", + }); + }); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledTimes(1); - expect( - vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledWith({ - value: false, - key: "non-existent", - version: undefined, + it("sends check event for not-enabled features", async () => { + // disabled features don't appear in the API response + vi.spyOn(FeaturesClient.prototype, "sendCheckEvent"); + + const client = new BucketClient({ publishableKey: KEY }); + await client.initialize(); + + const nonExistentFeature = client.getFeature("non-existent"); + + expect( + vi.mocked(FeaturesClient.prototype.sendCheckEvent), + ).toHaveBeenCalledTimes(0); + expect(nonExistentFeature.isEnabled).toBe(false); + + expect( + vi.mocked(FeaturesClient.prototype.sendCheckEvent), + ).toHaveBeenCalledTimes(1); + expect( + vi.mocked(FeaturesClient.prototype.sendCheckEvent), + ).toHaveBeenCalledWith({ + value: false, + key: "non-existent", + version: undefined, + }); }); - }); - describe("getFeature", async () => { it("calls client.track with the featureId", async () => { const client = new BucketClient({ publishableKey: KEY }); await client.initialize(); const featureId1 = client.getFeature("featureId1"); - expect(featureId1).toEqual({ + expect(featureId1).toStrictEqual({ isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), + config: { key: undefined, payload: undefined }, }); vi.spyOn(client, "track"); @@ -492,10 +563,11 @@ describe(`sends "check" events `, () => { await client.initialize(); const featureId1 = client.getFeature("featureId1"); - expect(featureId1).toEqual({ + expect(featureId1).toStrictEqual({ isEnabled: false, track: expect.any(Function), requestFeedback: expect.any(Function), + config: { key: undefined, payload: undefined }, }); vi.spyOn(client, "requestFeedback"); diff --git a/packages/browser-sdk/vite.config.mjs b/packages/browser-sdk/vite.config.mjs index 73a36a74..e5a93486 100644 --- a/packages/browser-sdk/vite.config.mjs +++ b/packages/browser-sdk/vite.config.mjs @@ -19,21 +19,23 @@ export default defineConfig({ exclude: ["**/node_modules/**", "test/e2e/**"], sourcemap: true, lib: { - // Could also be a dictionary or array of multiple entry points - entry: resolve(__dirname, "src/index.ts"), - name: "BucketBrowserSDK", - // the proper extensions will be added - fileName: "bucket-browser-sdk", + entry: { + index: resolve(__dirname, "src/index.ts"), + configuredClient: resolve(__dirname, "src/configuredClient.ts"), + }, + formats: ["es", "amd"], + fileName: "bucket-browser-sdk-[name]", }, rollupOptions: { // make sure to externalize deps that shouldn't be bundled // into your library - // external: ["vue"], + external: ["_bucket"], output: { // Provide global variables to use in the UMD build // for externalized deps globals: { BucketClient: "BucketClient", + _bucket: "_bucket", }, }, }, diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 00000000..a1b94f12 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1 @@ +test/gen/** diff --git a/packages/cli/.prettierignore b/packages/cli/.prettierignore new file mode 100644 index 00000000..6c22d4c1 --- /dev/null +++ b/packages/cli/.prettierignore @@ -0,0 +1,3 @@ +dist +eslint-report.json +test/gen diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..ecfe9dff --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1 @@ +# cli diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts new file mode 100644 index 00000000..5ca93aca --- /dev/null +++ b/packages/cli/commands/features.ts @@ -0,0 +1,60 @@ +import { input } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; + +import { + addFeatureToConfig, + generatePackagedConfig, +} from "../services/features.js"; +import { configFileExists, getConfig, loadConfig } from "../utils/config.js"; +import { handleError } from "../utils/error.js"; + +export function registerFeaturesCommands(program: Command) { + const featuresCommand = new Command("features").description( + "Manage features", + ); + + featuresCommand + .command("generate") + .description("Generate feature types") + .option("--ignore-missing-config", "Ignore missing config") + .action(async ({ ignoreMissingConfig }) => { + try { + if (!(await configFileExists()) && ignoreMissingConfig) return; + + const { features, codeGenBasePath } = await loadConfig(); + + await generatePackagedConfig(features, codeGenBasePath); + console.log(chalk.green(`Generated typed features.`)); + } catch (error) { + handleError(error, "Failed to generate feature types:"); + } + }); + + featuresCommand + .command("add") + .description("Add a new feature") + .argument("[key]", "Key for the feature") + .action(async (key) => { + await loadConfig(); + try { + if (key === undefined) { + key = await input({ + message: "Feature key", + required: true, + }); + } + await addFeatureToConfig({ key, access: true, configType: undefined }); + console.log(chalk.green(`Added feature "${key}"`)); + } catch (error) { + handleError(error, "Failed to create feature:"); + } + + await generatePackagedConfig( + getConfig().features, + getConfig().codeGenBasePath, + ); + }); + + program.addCommand(featuresCommand); +} diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts new file mode 100644 index 00000000..f5c61616 --- /dev/null +++ b/packages/cli/commands/init.ts @@ -0,0 +1,31 @@ +import chalk from "chalk"; +import { Command } from "commander"; + +import { + findRepoConfig, + initConfig, + writeConfigFile, +} from "../utils/config.js"; +import { handleError } from "../utils/error.js"; + +export function registerInitCommands(program: Command) { + program + .command("init") + .description("Initialize Bucket for new repository") + .action(async () => { + try { + const configFile = await findRepoConfig(); + if (configFile) { + console.log( + chalk.white(`Config file already exists at ${configFile}.`), + ); + return; + } + + await writeConfigFile(initConfig, "bucket.config.json"); + chalk.green(`Bucket bucket.config.json written!`); + } catch (error) { + handleError(error, "Failed to initialize"); + } + }); +} diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs new file mode 100644 index 00000000..29ff03ff --- /dev/null +++ b/packages/cli/eslint.config.mjs @@ -0,0 +1,3 @@ +import base from "@bucketco/eslint-config/base.js"; + +export default [...base, { ignores: ["dist/", "test/gen/"] }]; diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100755 index 00000000..177437f0 --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { program } from "commander"; + +import { registerFeaturesCommands } from "./commands/features.js"; +import { registerInitCommands } from "./commands/init.js"; + +async function main() { + // Main program + registerFeaturesCommands(program); + registerInitCommands(program); + + program.parse(process.argv); +} + +void main(); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..e1ec38f4 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,53 @@ +{ + "name": "@bucketco/cli", + "version": "0.1.0-alpha.0", + "packageManager": "yarn@4.1.1", + "description": "CLI for Bucket service", + "license": "MIT", + "main": "./dist/index.js", + "bin": { + "bucket": "./dist/index.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/bucketco/bucket-javascript-sdk.git" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "type": "module", + "scripts": { + "build": "tsc", + "start": "yarn build && node dist/index.js", + "test": "vitest --config ./vite.config.js", + "lint": "eslint .", + "lint:ci": "eslint --output-file eslint-report.json --format json .", + "prettier": "prettier --check .", + "format": "yarn lint --fix && yarn prettier --write" + }, + "dependencies": { + "@inquirer/prompts": "^7.2.3", + "@sindresorhus/slugify": "^2.2.1", + "axios": "^1.7.5", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "find-up": "^7.0.0", + "fs-extra": "^11.2.0", + "open": "^10.1.0", + "ora": "^8.1.0", + "strip-json-comments": "^5.0.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@bucketco/eslint-config": "workspace:^", + "@bucketco/tsconfig": "workspace:^", + "@types/fs-extra": "^11.0.4", + "@types/node": "^22.5.1", + "ts-node": "^10.9.2", + "typescript": "^5.5.4", + "vitest": "^2.1.8" + } +} diff --git a/packages/cli/services/__snapshots__/features.test.ts.snap b/packages/cli/services/__snapshots__/features.test.ts.snap new file mode 100644 index 00000000..6831eabd --- /dev/null +++ b/packages/cli/services/__snapshots__/features.test.ts.snap @@ -0,0 +1,87 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`genFeatureTypes > should generate correct TypeScript output for browser > index.d.ts 1`] = ` +"// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + +export const generatedFeatures: string[]; +export interface GeneratedFeatureTypes { + feature1: { + key: "feature1", + access: true, + configType: undefined + }, + feature2: { + key: "feature2", + access: false, + configType: string + }, + feature3: { + key: "feature3", + access: true, + configType: { + aiModel: string, + prompt: { + text: string, + cheekyFactor: number + } + } + } +}" +`; + +exports[`genFeatureTypes > should generate correct TypeScript output for browser > index.d.ts 2`] = ` +"// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + +export const localFeatures = [ + { + key: "feature1", + access: true, + configType: undefined, + fallback: undefined + }, + { + key: "feature2", + access: false, + configType: "string", + fallback: undefined + }, + { + key: "feature3", + access: true, + configType: { + aiModel: "string", + prompt: { + text: "string", + cheekyFactor: "number" + } + }, + fallback: { + isEnabled: true, + config: { + cheekyFactorType: "number", + aiModel: "gpt3", + prompt: { + text: "Explain in conversational language", + cheekyFactor: 0.5 + } + } + } + } +];" +`; + +exports[`genFeatureTypes > should generate correct TypeScript output for browser > index.d.ts 3`] = ` +"{ + "name": "_bucket", + "version": "1.0.0", + "description": "Autogenerated package for typed features for Bucket", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "import": "./index.js", + "types": "./index.d.ts" + } + } +}" +`; diff --git a/packages/cli/services/features.test.ts b/packages/cli/services/features.test.ts new file mode 100644 index 00000000..578acefe --- /dev/null +++ b/packages/cli/services/features.test.ts @@ -0,0 +1,57 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import { describe, expect, it } from "vitest"; + +import { FeatureDef } from "../utils/config.js"; + +import { generatePackagedConfig } from "./features.js"; + +describe("genFeatureTypes", () => { + const features: FeatureDef[] = [ + { key: "feature1" }, + { key: "feature2", access: false, configType: "string" }, + { + key: "feature3", + configType: { + aiModel: "string", + prompt: { + text: "string", + cheekyFactor: "number", + }, + }, + fallback: { + isEnabled: true, + config: { + cheekyFactorType: "number", // this will trip up a simple output writer + aiModel: "gpt3", + prompt: { + text: "Explain in conversational language", + cheekyFactor: 0.5, + }, + }, + }, + }, + ]; + + it("should generate correct TypeScript output for browser", async () => { + const outputDir = "test/gen"; + await generatePackagedConfig(features, outputDir); + const dtsOutput = await readFile( + path.join(outputDir, "_bucket/index.d.ts"), + "utf-8", + ); + expect(dtsOutput).toMatchSnapshot("index.d.ts"); + + const jsOutput = await readFile( + path.join(outputDir, "_bucket/index.js"), + "utf-8", + ); + expect(jsOutput).toMatchSnapshot("index.d.ts"); + + const packageJsonOutput = await readFile( + path.join(outputDir, "_bucket/package.json"), + "utf-8", + ); + expect(packageJsonOutput).toMatchSnapshot("index.d.ts"); + }); +}); diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts new file mode 100644 index 00000000..8c73cf4b --- /dev/null +++ b/packages/cli/services/features.ts @@ -0,0 +1,59 @@ +import path from "path"; +import { outputFile } from "fs-extra"; + +import { + FeatureDef, + generatedPackageName, + readConfigFile, + writeConfigFile, +} from "../utils/config.js"; +import { genDTS, genJs } from "../utils/gen.js"; + +const packageJson = { + name: generatedPackageName, + version: "1.0.0", + description: "Autogenerated package for typed features for Bucket", + main: "index.js", + types: "index.d.ts", + exports: { + ".": { + import: "./index.js", + types: "./index.d.ts", + }, + }, +}; + +export async function generatePackagedConfig( + configFeatures: FeatureDef[], + basePath: string, +) { + const features = configFeatures.map((feature) => ({ + key: feature.key, + access: feature.access ?? true, + configType: feature.configType, + fallback: feature.fallback, + })); + + await outputFile( + path.join(basePath, generatedPackageName, `index.d.ts`), + genDTS(features), + ); + await outputFile( + path.join(basePath, generatedPackageName, `index.js`), + genJs(features), + ); + await outputFile( + path.join(basePath, generatedPackageName, `package.json`), + JSON.stringify(packageJson, null, 2), + ); +} + +export async function addFeatureToConfig(feature: FeatureDef) { + const config = await readConfigFile(); + + if (feature.access && feature.configType === undefined) + config.features.push(feature.key); + else config.features.push(feature); + + await writeConfigFile(config); +} diff --git a/packages/cli/tsconfig.eslint.json b/packages/cli/tsconfig.eslint.json new file mode 100644 index 00000000..fc8520e7 --- /dev/null +++ b/packages/cli/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..e1676ae1 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@bucketco/tsconfig/library", + "compilerOptions": { + "outDir": "./dist/", + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "nodenext" + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.spec.ts"] +} diff --git a/packages/cli/utils/__snapshots__/gen.test.ts.snap b/packages/cli/utils/__snapshots__/gen.test.ts.snap new file mode 100644 index 00000000..00284887 --- /dev/null +++ b/packages/cli/utils/__snapshots__/gen.test.ts.snap @@ -0,0 +1,60 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`genFeatureTypes > should generate correct TypeScript declaration 1`] = ` +"// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + +export const generatedFeatures: string[]; +export interface GeneratedFeatureTypes { + "feat-1": { + key: "feat-1", + access: true, + configType: undefined + }, + "FEAT URE2": { + key: "FEAT URE2", + access: false, + configType: string + }, + feature3: { + key: "feature3", + access: true, + configType: { + aiModel: string, + prompt: string + } + } +}" +`; + +exports[`genFeatureTypes > should generate correct javascript file 1`] = ` +"// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + +export const localFeatures = [ + { + key: "feat-1", + access: true, + configType: undefined + }, + { + key: "FEAT URE2", + access: false, + configType: "string" + }, + { + key: "feature3", + access: true, + configType: { + aiModel: "string", + prompt: "string" + }, + fallback: { + isEnabled: true, + config: { + type: "number", + aiModel: "gtp3.5-turbo", + prompt: "Explain in conversational language" + } + } + } +];" +`; diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts new file mode 100644 index 00000000..956e0748 --- /dev/null +++ b/packages/cli/utils/config.ts @@ -0,0 +1,115 @@ +import path from "path"; +import { findUp } from "find-up"; +import { readJson, writeJson } from "fs-extra/esm"; +import { z } from "zod"; + +import { REPO_CONFIG_FILE } from "./constants.js"; +import { Datatype } from "./gen.js"; + +const ConfigSchema = z.object({ + features: z.array( + z.union([ + z.string(), + z.object({ + key: z.string(), + access: z.boolean().optional(), + config: z.any().optional(), + }), + ]), + ), + codeGenBasePath: z.string().optional(), +}); + +export const initConfig = { + features: [], +} satisfies z.input; + +export const generatedPackageName = "_bucket"; + +type Config = { + features: FeatureDef[]; + codeGenBasePath: string; +}; + +export type FeatureDef = { + key: string; + access?: boolean; + configType?: Datatype; + fallback?: FallbackValue; +}; + +export type FallbackValue = { + isEnabled: boolean; + config: any; +}; + +let loadedConfig: Config = { + features: [], + codeGenBasePath: "", +}; + +/** + * Instantly return a specified key's value or the entire config object. + */ +export function getConfig(): Config; +export function getConfig(key?: keyof Config) { + return key ? loadedConfig[key] : loadedConfig; +} + +export async function findRepoConfig() { + return await findUp(REPO_CONFIG_FILE); +} + +/** + * Read the config file and return either a specified key's value or the entire config object. + */ +export async function readConfigFile() { + const location = await findRepoConfig(); + if (!location) { + throw new Error("No bucket.config.js file found."); + } + const parseResult = ConfigSchema.safeParse(await readJson(location)); + if (parseResult.success) { + return parseResult.data; + } + + throw new Error("Failed to parse config file: " + parseResult.error.message); +} + +/** + * Write a new value to the config file. + */ +export async function writeConfigFile(config: object, location?: string) { + const writePath = location ? location : await findRepoConfig(); + if (!writePath) + throw new Error("writeConfigFile: Could not find config file."); + await writeJson(writePath, config, { spaces: 2 }); +} + +export async function loadConfig() { + const readConfig = await readConfigFile(); + + // normalize features to have a key + const features: FeatureDef[] = readConfig.features.map((feature) => { + if (typeof feature === "string") { + return { key: feature }; + } + return feature; + }); + + loadedConfig = { + features, + codeGenBasePath: + readConfig.codeGenBasePath ?? (await defaultCodeGenBasePath()), + }; + return loadedConfig; +} + +export async function configFileExists() { + return !!(await findRepoConfig()); +} + +export async function defaultCodeGenBasePath() { + const confLocation = (await findRepoConfig()) ?? ""; + return path.join(path.dirname(confLocation), "node_modules"); +} diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts new file mode 100644 index 00000000..d6c14449 --- /dev/null +++ b/packages/cli/utils/constants.ts @@ -0,0 +1 @@ +export const REPO_CONFIG_FILE = "bucket.config.json"; diff --git a/packages/cli/utils/error.ts b/packages/cli/utils/error.ts new file mode 100644 index 00000000..e87e7976 --- /dev/null +++ b/packages/cli/utils/error.ts @@ -0,0 +1,31 @@ +import { AxiosError } from "axios"; +import chalk from "chalk"; + +export function handleError(error: unknown, message?: string | null): never { + if (error instanceof AxiosError && error.response?.data) { + const data = error.response.data; + console.error( + chalk.red( + message ?? "Network request error:", + data.error?.message ?? data.error?.code, + ), + ); + if (data.validationErrors) { + console.table( + data.validationErrors.map( + ({ path, message: errMsg }: { path: string[]; message: string }) => ({ + path: path.join("."), + error: errMsg, + }), + ), + ); + } + } else if (error instanceof Error) { + console.error(message, error.message); + } else if (typeof error === "string") { + console.error(message, error); + } else { + console.error(message ?? "An unknown error occurred:", error); + } + process.exit(1); +} diff --git a/packages/cli/utils/gen.test.ts b/packages/cli/utils/gen.test.ts new file mode 100644 index 00000000..ecbf0bc5 --- /dev/null +++ b/packages/cli/utils/gen.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { FeatureDef } from "./config.js"; +import { genDTS, genJs } from "./gen.js"; + +describe("genFeatureTypes", () => { + const features: FeatureDef[] = [ + { key: "feat-1", access: true, configType: undefined }, + { key: "FEAT URE2", access: false, configType: "string" }, + { + key: "feature3", + access: true, + configType: { + aiModel: "string", + prompt: "string", + }, + fallback: { + isEnabled: true, + config: { + type: "number", // this will trip up a simple output writer + aiModel: "gtp3.5-turbo", + prompt: "Explain in conversational language", + }, + }, + }, + ]; + + it("should generate correct TypeScript declaration", () => { + const output = genDTS(features); + expect(output).toMatchSnapshot(); + }); + + it("should generate correct javascript file", () => { + const output = genJs(features); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts new file mode 100644 index 00000000..1701193e --- /dev/null +++ b/packages/cli/utils/gen.ts @@ -0,0 +1,104 @@ +import slugify from "@sindresorhus/slugify"; + +import { FeatureDef } from "./config.js"; + +export type Datatype = + | "string" + | "boolean" + | "number" + | { [key: string]: Datatype }; + +const indentString = (str: string, count: number): string => + str.replace(/^/gm, " ".repeat(count)); + +function maybeStringify(value: any, stringify: boolean): string { + return stringify ? `"${value}"` : value; +} + +function formatObjKey(key: string): string { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key) ? key : `"${key}"`; +} + +function outputType( + value: any, + stringTypes: boolean, + indent: number = 0, +): string { + if (value === "boolean") return maybeStringify("boolean", stringTypes); + if (value === "string") return maybeStringify("string", stringTypes); + if (value === "number") return maybeStringify("number", stringTypes); + + if (Array.isArray(value)) + return indentString( + `[\n` + + value + .map((v: any) => indentString(outputType(v, stringTypes, indent), 1)) + .join(",\n") + + `\n]\n`, + indent, + ); + + if (typeof value === "object") + return indentString( + `{\n` + + indentString( + Object.entries(value) + .map( + ([k, v]) => `${formatObjKey(k)}: ${outputType(v, stringTypes)}`, + ) + .join(",\n"), + 1, + ) + + `\n}`, + indent, + ); + + return JSON.stringify(value); +} + +export function genJs(features: FeatureDef[]): string { + return ` +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + +export const localFeatures = ${outputType(features, true).trimEnd()}; + +`.trim(); +} + +export function genDTS(features: FeatureDef[]): string { + const featureTypes = Object.fromEntries( + features.map((feature) => [ + feature.key, + { + key: feature.key, + access: feature.access, + configType: feature.configType, + }, + ]), + ); + + return ` +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + +export const generatedFeatures: string[]; +export interface GeneratedFeatureTypes ${outputType(featureTypes, false)} + `.trim(); +} + +export function genFeatureKey(input: string, existingKeys: string[]): string { + const keySlug = slugify(input); + + if (!existingKeys.includes(keySlug)) { + return keySlug; + } else { + const lastPart = keySlug.split("-").pop(); + + if (!lastPart || isNaN(Number(lastPart))) { + return `${keySlug}-1`; + } else { + const base = keySlug.slice(0, keySlug.length - lastPart.length); + const newNumber = Number(lastPart) + 1; + return `${base}${newNumber}`; + } + } +} diff --git a/packages/cli/vite.config.js b/packages/cli/vite.config.js new file mode 100644 index 00000000..23b5454d --- /dev/null +++ b/packages/cli/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + test: { + exclude: ["dist/**"], + forceRerunTriggers: ["**/vitest.config.*/**", "**/vite.config.*/**"], // remove '**/package.json/**' from the default value to avoid rerun on test/gen/package.json changes + }, +}); diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 63441612..b673c9f2 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -74,13 +74,18 @@ const boundClient = bucketClient.bindClient({ // get the huddle feature using company, user and custom context to // evaluate the targeting. -const { isEnabled, track } = boundClient.getFeature("huddle"); +const { isEnabled, track, config } = boundClient.getFeature("huddle"); if (isEnabled) { // this is your feature gated code ... // send an event when the feature is used: track(); + if (config?.key === "zoom") { + // this code will run if a given remote configuration + // is set up. + } + // CAUTION: if you plan to use the event for automated feedback surveys // call `flush` immediately after `track`. It can optionally be awaited // to guarantee the sent happened. @@ -108,6 +113,34 @@ to `getFeatures()` (or through `bindClient(..).getFeatures()`). That means the `initialize()` has completed. `BucketClient` will continue to periodically download the targeting rules from the Bucket servers in the background. +### Remote config + +Similar to `isEnabled`, each feature has a `config` property. This configuration is managed from within Bucket. +It is managed similar to the way access to features is managed, but instead of the binary `isEnabled` you can have +multiple configuration values which are given to different user/companies. + +```ts +const features = bucketClient.getFeatures(); +// { +// huddle: { +// isEnabled: true, +// targetingVersion: 42, +// config: { +// key: "gpt-3.5", +// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } +// } +// } +// } +``` + +The `key` is always present while the `payload` is a optional JSON value for arbitrary configuration needs. +If feature has no configuration or, no configuration value was matched against the context, the `config` object +will be empty, thus, `key` will be `undefined`. Make sure to check against this case when trying to use the +configuration in your application. + +Just as `isEnabled`, accessing `config` on the object returned by `getFeatures` does not automatically +generate a `check` event, contrary to the `config` property on the object returned by `getFeature`. + ## Configuring The Bucket `Node.js` SDK can be configured through environment variables, @@ -136,7 +169,13 @@ Note: BUCKET_FEATURES_ENABLED, BUCKET_FEATURES_DISABLED are comma separated list "apiBaseUrl": "https://proxy.slick-demo.com", "featureOverrides": { "huddles": true, - "voiceChat": false + "voiceChat": false, + "aiAssist": { + "key": "gpt-4.0", + "payload": { + "maxTokens": 50000 + } + } } } ``` @@ -162,8 +201,11 @@ import { BucketClient } from "@bucketco/node-sdk"; declare module "@bucketco/node-sdk" { interface Features { "show-todos": boolean; - "create-todos": boolean; - "delete-todos": boolean; + "create-todos": { isEnabled: boolean }; + "delete-todos": { + isEnabled: boolean, + config: any + }; } } @@ -173,7 +215,52 @@ bucketClient.initialize().then({ console.log("Bucket initialized!") bucketClient.getFeature("invalid-feature") // feature doesn't exist }) +``` + +The following example show how to add strongly typed payloads when using remote configuration: + +```typescript +import { BucketClient } from "@bucketco/node-sdk"; + +type ConfirmationConfig = { + shouldShowConfirmation: boolean; +}; +declare module "@bucketco/node-sdk" { + interface Features { + "delete-todos": { + isEnabled: boolean; + config: { + key: string; + payload: ConfirmationConfig; + }; + }; + } +} + +export const bucketClient = new BucketClient(); + +function deleteTodo(todoId: string) { + // get the feature information + const { + isEnabled, + config: { payload: confirmationConfig }, + } = bucketClient.getFeature("delete-todos"); + + // check that feature is enabled for user + if (!isEnabled) { + return; + } + + // finally, check if we enabled the "confirmation" dialog for this user and only + // show it in that case. + // since we defined `ConfirmationConfig` as the only valid payload for `delete-todos`, + // we have type-safety helping us with the payload value. + if (confirmationConfig.shouldShowConfirmation) { + showMessage("Are you really sure you want to delete this item?"); + // ... rest of the code + } +} ``` ![Type check failed](docs/type-check-failed.png "Type check failed") diff --git a/packages/node-sdk/example/app.ts b/packages/node-sdk/example/app.ts index 37812f17..31fca265 100644 --- a/packages/node-sdk/example/app.ts +++ b/packages/node-sdk/example/app.ts @@ -65,10 +65,18 @@ app.post("/todos", (req, res) => { return res.status(400).json({ error: "Invalid todo" }); } - const { track, isEnabled } = res.locals.bucketUser.getFeature("create-todos"); + const { track, isEnabled, config } = + res.locals.bucketUser.getFeature("create-todos"); // Check if the user has the "create-todos" feature enabled if (isEnabled) { + // Check if the todo is at least N characters long + if (todo.length < config.payload.minimumLength) { + return res + .status(400) + .json({ error: "Todo must be at least 5 characters long" }); + } + // Track the feature usage track(); todos.push(todo); diff --git a/packages/node-sdk/example/bucket.ts b/packages/node-sdk/example/bucket.ts index 6aebcfd8..907c4deb 100644 --- a/packages/node-sdk/example/bucket.ts +++ b/packages/node-sdk/example/bucket.ts @@ -1,17 +1,38 @@ import { BucketClient, Context } from "../src"; import { FeatureOverrides } from "../src/types"; +type CreateConfig = { + minimumLength: number; +}; + // Extending the Features interface to define the available features declare module "../src/types" { interface Features { "show-todos": boolean; - "create-todos": boolean; + "create-todos": { + isEnabled: boolean; + config: { + key: string; + payload: CreateConfig; + }; + }; "delete-todos": boolean; + "some-else": {}; } } -let featureOverrides = (context: Context): FeatureOverrides => { - return { "delete-todos": true }; // feature keys checked at compile time +let featureOverrides = (_: Context): FeatureOverrides => { + return { + "create-todos": { + isEnabled: true, + config: { + key: "short", + payload: { + minimumLength: 10, + }, + }, + }, + }; // feature keys checked at compile time }; let host = undefined; diff --git a/packages/node-sdk/example/bucketConfig.json b/packages/node-sdk/example/bucketConfig.json index e7c2bf24..b4f55d97 100644 --- a/packages/node-sdk/example/bucketConfig.json +++ b/packages/node-sdk/example/bucketConfig.json @@ -1,6 +1,6 @@ { "overrides": { - "myFeature": true, - "myFeatureFalse": false + "show-todos": true, + "create-todos": true } } diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index ba55d857..dfe0278b 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -17,9 +17,11 @@ import fetchClient from "./fetch-http-client"; import { newRateLimiter } from "./rate-limiter"; import type { EvaluatedFeaturesAPIResponse, + FeatureAPIResponse, FeatureOverridesFn, IdType, RawFeature, + RawFeatureRemoteConfig, } from "./types"; import { Attributes, @@ -27,7 +29,6 @@ import { ClientOptions, Context, ContextWithTracking, - Feature, FeatureEvent, FeaturesAPIResponse, HttpClient, @@ -102,6 +103,7 @@ export class BucketClient { offline: boolean; configFile?: string; }; + private _initialize = once(async () => { if (!this._config.offline) { await this.getFeaturesCache().refresh(); @@ -140,8 +142,9 @@ export class BucketClient { ); ok( options.fallbackFeatures === undefined || - Array.isArray(options.fallbackFeatures), - "fallbackFeatures must be an object", + Array.isArray(options.fallbackFeatures) || + isObject(options.fallbackFeatures), + "fallbackFeatures must be an array or object", ); ok( options.batchOptions === undefined || isObject(options.batchOptions), @@ -179,18 +182,40 @@ export class BucketClient { // todo: deprecate fallback features in favour of a more operationally // friendly way of setting fall backs. - const fallbackFeatures = - options.fallbackFeatures && - options.fallbackFeatures.reduce( - (acc, key) => { - acc[key as keyof TypedFeatures] = { - isEnabled: true, - key, - }; - return acc; - }, - {} as Record, - ); + const fallbackFeatures = Array.isArray(options.fallbackFeatures) + ? options.fallbackFeatures.reduce( + (acc, key) => { + acc[key as keyof TypedFeatures] = { + isEnabled: true, + key, + }; + return acc; + }, + {} as Record, + ) + : isObject(options.fallbackFeatures) + ? Object.entries(options.fallbackFeatures).reduce( + (acc, [key, fallback]) => { + acc[key as keyof TypedFeatures] = { + isEnabled: + typeof fallback === "object" + ? fallback.isEnabled + : !!fallback, + key, + config: + typeof fallback === "object" && fallback.config + ? { + key: fallback.config.key, + default: true, + payload: fallback.config.payload, + } + : undefined, + }; + return acc; + }, + {} as Record, + ) + : undefined; this._config = { logger, @@ -439,10 +464,10 @@ export class BucketClient { * @remarks * Call `initialize` before calling this method to ensure the feature definitions are cached, no features will be returned otherwise. **/ - public getFeature( + public getFeature( { enableTracking = true, ...context }: ContextWithTracking, - key: keyof TypedFeatures, - ) { + key: TKey, + ): TypedFeatures[TKey] { const options = { enableTracking, ...context }; const features = this._getFeatures(options); const feature = features[key]; @@ -451,6 +476,7 @@ export class BucketClient { key, isEnabled: feature?.isEnabled ?? false, targetingVersion: feature?.targetingVersion, + config: feature?.config, }); } @@ -486,12 +512,12 @@ export class BucketClient { * @param additionalContext * @returns evaluated feature */ - public async getFeatureRemote( - key: string, + public async getFeatureRemote( + key: TKey, userId?: IdType, companyId?: IdType, additionalContext?: Context, - ): Promise { + ): Promise { const features = await this._getFeaturesRemote( key, userId, @@ -811,8 +837,12 @@ export class BucketClient { featureDefinitions = fetchedFeatures.features; } - const keyToVersionMap = new Map( - featureDefinitions.map((f) => [f.key, f.targeting.version]), + const featureMap = featureDefinitions.reduce( + (acc, f) => { + acc[f.key] = f; + return acc; + }, + {} as Record, ); const { enableTracking = true, ...context } = options; @@ -825,6 +855,31 @@ export class BucketClient { }), ); + const evaluatedConfigs = evaluated.reduce( + (acc, { featureKey }) => { + const feature = featureMap[featureKey]; + if (feature.config) { + const variant = evaluateFeatureRules({ + featureKey, + rules: feature.config.variants.map(({ filter, ...rest }) => ({ + filter, + value: rest, + })), + context, + }); + + if (variant.value) { + acc[featureKey] = { + ...variant.value, + targetingVersion: feature.config.version, + }; + } + } + return acc; + }, + {} as Record, + ); + this.warnMissingFeatureContextFields( context, evaluated.map(({ featureKey, missingContextFields }) => ({ @@ -839,7 +894,7 @@ export class BucketClient { await this.sendFeatureEvent({ action: "evaluate", key: res.featureKey, - targetingVersion: keyToVersionMap.get(res.featureKey), + targetingVersion: featureMap[res.featureKey].targeting.version, evalResult: res.value ?? false, evalContext: res.context, evalRuleResults: res.ruleEvaluationResults, @@ -859,7 +914,9 @@ export class BucketClient { acc[res.featureKey as keyof TypedFeatures] = { key: res.featureKey, isEnabled: res.value ?? false, - targetingVersion: keyToVersionMap.get(res.featureKey), + config: evaluatedConfigs[res.featureKey], + targetingVersion: featureMap[res.featureKey].targeting.version, + missingContextFields: res.missingContextFields, }; return acc; }, @@ -869,7 +926,14 @@ export class BucketClient { // apply feature overrides const overrides = Object.entries( this._config.featureOverrides(context), - ).map(([key, isEnabled]) => [key, { key, isEnabled }]); + ).map(([key, override]) => [ + key, + { + key, + isEnabled: isObject(override) ? override.isEnabled : !!override, + config: isObject(override) ? override.config : undefined, + }, + ]); if (overrides.length > 0) { // merge overrides into evaluated features @@ -878,38 +942,48 @@ export class BucketClient { ...Object.fromEntries(overrides), }; } - this._config.logger?.debug("evaluated features", evaluatedFeatures); return evaluatedFeatures; } - private _wrapRawFeature( + private _wrapRawFeature( options: { enableTracking: boolean } & Context, - { key, isEnabled, targetingVersion }: RawFeature, - ): Feature { + { key, isEnabled, config, targetingVersion }: RawFeature, + ): TypedFeatures[TKey] { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; + function sendCheckEvent() { + if (options.enableTracking) { + void client + .sendFeatureEvent({ + action: "check", + key, + targetingVersion, + evalResult: isEnabled, + }) + .catch((err) => { + client._config.logger?.error( + `failed to send check event for "${key}": ${err}`, + err, + ); + }); + } + } + + const simplifiedConfig = config + ? { key: config.key, payload: config.payload } + : { key: undefined, payload: undefined }; + return { get isEnabled() { - if (options.enableTracking) { - void client - .sendFeatureEvent({ - action: "check", - key, - targetingVersion, - evalResult: isEnabled, - }) - .catch((err) => { - client._config.logger?.error( - `failed to send check event for "${key}": ${err}`, - err, - ); - }); - } - + sendCheckEvent(); return isEnabled; }, + get config() { + sendCheckEvent(); + return simplifiedConfig as TypedFeatures[TKey]["config"]; + }, key, track: async () => { if (typeof options.user?.id === "undefined") { @@ -946,6 +1020,7 @@ export class BucketClient { ...context, enableTracking: true, }; + checkContextWithTracking(contextWithTracking); const params = new URLSearchParams( @@ -965,6 +1040,7 @@ export class BucketClient { context, Object.values(res.features), ); + return Object.fromEntries( Object.entries(res.features).map(([featureKey, feature]) => { return [ @@ -1032,7 +1108,7 @@ export class BoundBucketClient { * * @returns Features for the given user/company and whether each one is enabled or not */ - public getFeatures() { + public getFeatures(): TypedFeatures { return this._client.getFeatures(this._options); } @@ -1042,7 +1118,9 @@ export class BoundBucketClient { * * @returns Features for the given user/company and whether each one is enabled or not */ - public getFeature(key: keyof TypedFeatures) { + public getFeature( + key: TKey, + ): TypedFeatures[TKey] { return this._client.getFeature(this._options, key); } diff --git a/packages/node-sdk/src/config.ts b/packages/node-sdk/src/config.ts index 503629c2..b6ec95e2 100644 --- a/packages/node-sdk/src/config.ts +++ b/packages/node-sdk/src/config.ts @@ -3,7 +3,7 @@ import { readFileSync } from "fs"; import { version } from "../package.json"; import { LOG_LEVELS } from "./types"; -import { ok } from "./utils"; +import { isObject, ok } from "./utils"; export const API_BASE_URL = "https://front.bucket.co"; export const SDK_VERSION_HEADER_NAME = "bucket-sdk-version"; @@ -21,19 +21,33 @@ export const BATCH_INTERVAL_MS = 10 * 1000; function parseOverrides(config: object | undefined) { if (!config) return {}; - if ( - "featureOverrides" in config && - typeof config.featureOverrides === "object" - ) { - const overrides = config.featureOverrides as object; - Object.entries(overrides).forEach(([key, value]) => { + if ("featureOverrides" in config && isObject(config.featureOverrides)) { + Object.entries(config.featureOverrides).forEach(([key, value]) => { ok( - typeof value === "boolean", - `invalid type "${typeof value}" for key ${key}, expected boolean`, + typeof value === "boolean" || isObject(value), + `invalid type "${typeof value}" for key ${key}, expected boolean or object`, ); + if (isObject(value)) { + ok( + "isEnabled" in value && typeof value.isEnabled === "boolean", + `invalid type "${typeof value.isEnabled}" for key ${key}.isEnabled, expected boolean`, + ); + ok( + value.config === undefined || isObject(value.config), + `invalid type "${typeof value.config}" for key ${key}.config, expected object or undefined`, + ); + if (isObject(value.config)) { + ok( + "key" in value.config && typeof value.config.key === "string", + `invalid type "${typeof value.config.key}" for key ${key}.config.key, expected string`, + ); + } + } }); - return overrides; + + return config.featureOverrides; } + return {}; } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 25b8b8b2..e8637774 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -56,7 +56,32 @@ export type FeatureEvent = { }; /** - * Describes a feature + * A remotely managed configuration value for a feature. + */ +export type RawFeatureRemoteConfig = { + /** + * The key of the matched configuration value. + */ + key: string; + + /** + * The version of the targeting rules used to select the config value. + */ + targetingVersion?: number; + + /** + * Indicates if the config value is the default. + */ + default: boolean; + + /** + * The optional user-supplied payload data. + */ + payload: any; +}; + +/** + * Describes a feature. */ export interface RawFeature { /** @@ -74,16 +99,42 @@ export interface RawFeature { */ targetingVersion?: number; + /** + * The remote configuration value for the feature. + */ + config?: RawFeatureRemoteConfig; + /** * The missing fields in the evaluation context (optional). */ missingContextFields?: string[]; } +type EmptyFeatureRemoteConfig = { key: undefined; payload: undefined }; + +/** + * 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; + } + | EmptyFeatureRemoteConfig; + /** * Describes a feature */ -export interface Feature { +export interface Feature< + TConfig extends FeatureRemoteConfig | never = EmptyFeatureRemoteConfig, +> { /** * The key of the feature. */ @@ -94,12 +145,27 @@ export interface Feature { */ isEnabled: boolean; + /* + * Optional user-defined configuration. + */ + config: TConfig extends never ? EmptyFeatureRemoteConfig : TConfig; + /** * Track feature usage in Bucket. */ track(): Promise; } +type FullFeatureOverride = { + isEnabled: boolean; + config?: { + key: string; + payload: any; + }; +}; + +type FeatureOverride = FullFeatureOverride | boolean; + /** * Describes a collection of evaluated features. * @@ -118,48 +184,141 @@ export interface Features {} */ export type TypedFeatures = keyof Features extends never ? Record - : Record; + : { + [FeatureKey in keyof Features]: Features[FeatureKey] extends FullFeatureOverride + ? Feature + : Feature; + }; + +type TypedFeatureKey = keyof TypedFeatures; /** * Describes the feature overrides. */ -export type FeatureOverrides = Partial>; +export type FeatureOverrides = Partial< + keyof Features extends never + ? Record + : { + [FeatureKey in keyof Features]: Features[FeatureKey] extends FullFeatureOverride + ? Features[FeatureKey] + : Exclude; + } +>; + export type FeatureOverridesFn = (context: Context) => FeatureOverrides; /** - * Describes a specific feature in the API response + * (Internal) Describes a remote feature config variant. + * + * @internal + */ +export type FeatureConfigVariant = { + /** + * The filter for the variant. + */ + filter: RuleFilter; + + /** + * The optional user-supplied payload data. + */ + payload: any; + + /** + * The key of the variant. + */ + key: string; + + /** + * Indicates if the variant is the default variant. + */ + default: boolean; +}; + +/** + * (Internal) Describes a specific feature in the API response. + * + * @internal */ -type FeatureAPIResponse = { +export type FeatureAPIResponse = { + /** + * The key of the feature. + */ key: string; + + /** + * The targeting rules for the feature. + */ targeting: { + /** + * The version of the targeting rules. + */ version: number; + + /** + * The targeting rules. + */ rules: { + /** + * The filter for the rule. + */ filter: RuleFilter; }[]; }; + + /** + * The remote configuration for the feature. + */ + config?: { + /** + * The version of the remote configuration. + */ + version: number; + + /** + * The variants of the remote configuration. + */ + variants: FeatureConfigVariant[]; + }; }; /** - * Describes the response of the features endpoint + * (Internal) Describes the response of the features endpoint. + * + * @internal */ export type FeaturesAPIResponse = { - /** The feature definitions */ + /** + * The feature definitions. + */ features: FeatureAPIResponse[]; }; +/** + * (Internal) Describes the response of the evaluated features endpoint. + * + * @internal + */ export type EvaluatedFeaturesAPIResponse = { - /** True if request successful */ + /** + * True if request successful. + */ success: boolean; - /** True if additional context for user or company was found and used for evaluation on the remote server */ + + /** + * True if additional context for user or company was found and used for evaluation on the remote server. + */ remoteContextUsed: boolean; - /** The feature definitions */ + + /** + * The feature definitions. + */ features: RawFeature[]; }; /** * Describes the response of a HTTP client. - * @typeParam TResponse - The type of the response body. * + * @typeParam TResponse - The type of the response body. */ export type HttpClientResponse = { /** @@ -330,8 +489,14 @@ export type ClientOptions = { /** * The features to "enable" as fallbacks when the API is unavailable (optional). + * Can be an array of feature keys, or a record of feature keys and boolean or object values. + * + * If a record is supplied instead of array, the values of each key are either the + * configuration values or the boolean value `true`. **/ - fallbackFeatures?: (keyof TypedFeatures)[]; + fallbackFeatures?: + | TypedFeatureKey[] + | Record>; /** * The HTTP client to use for sending requests (optional). Default is the built-in fetch client. @@ -347,16 +512,14 @@ export type ClientOptions = { /** * If a filename is specified, feature targeting results be overridden with * the values from this file. The file should be a JSON object with feature - * keys as keys and boolean values as values. + * keys as keys, and boolean or object as values. * * If a function is specified, the function will be called with the context - * and should return a record of feature keys and boolean values. + * and should return a record of feature keys and boolean or object values. * * Defaults to "bucketFeatures.json". **/ - featureOverrides?: - | string - | ((context: Context) => Partial>); + featureOverrides?: string | ((context: Context) => FeatureOverrides); /** * In offline mode, no data is sent or fetched from the the Bucket API. diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index ed6fac69..bc96e128 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -109,6 +109,22 @@ const featureDefinitions: FeaturesAPIResponse = { }, ], }, + config: { + version: 1, + variants: [ + { + filter: { + type: "context", + field: "company.id", + operator: "IS", + values: ["company123"], + }, + key: "config-1", + default: true, + payload: { something: "else" }, + }, + ], + }, }, { key: "feature2", @@ -146,6 +162,12 @@ const evaluatedFeatures = [ feature: { key: "feature1", version: 1 }, value: true, context: {}, + config: { + key: "config-1", + payload: { something: "else" }, + ruleEvaluationResults: [true], + missingContextFields: [], + }, ruleEvaluationResults: [true], missingContextFields: [], }, @@ -175,6 +197,57 @@ describe("BucketClient", () => { } }); + it("should accept fallback features as an array", async () => { + const bucketInstance = new BucketClient({ + secretKey: "validSecretKeyWithMoreThan22Chars", + fallbackFeatures: ["feature1", "feature2"], + }); + + expect(bucketInstance["_config"].fallbackFeatures).toEqual({ + feature1: { + isEnabled: true, + key: "feature1", + }, + feature2: { + isEnabled: true, + key: "feature2", + }, + }); + }); + + it("should accept fallback features as an object", async () => { + const bucketInstance = new BucketClient({ + secretKey: "validSecretKeyWithMoreThan22Chars", + fallbackFeatures: { + feature1: true, + feature2: { + isEnabled: true, + config: { + key: "config1", + payload: { value: true }, + }, + }, + }, + }); + + expect(bucketInstance["_config"].fallbackFeatures).toStrictEqual({ + feature1: { + key: "feature1", + config: undefined, + isEnabled: true, + }, + feature2: { + key: "feature2", + isEnabled: true, + config: { + default: true, + key: "config1", + payload: { value: true }, + }, + }, + }); + }); + it("should create a client instance with valid options", () => { const client = new BucketClient(validOptions); @@ -287,7 +360,7 @@ describe("BucketClient", () => { fallbackFeatures: "invalid" as any, }; expect(() => new BucketClient(invalidOptions)).toThrow( - "fallbackFeatures must be an object", + "fallbackFeatures must be an array or object", ); }); @@ -905,6 +978,8 @@ describe("BucketClient", () => { describe("getFeature", () => { let client: BucketClient; + let featureEvalSequence: Record; + beforeEach(async () => { httpClient.get.mockResolvedValue({ ok: true, @@ -917,12 +992,27 @@ describe("BucketClient", () => { client = new BucketClient(validOptions); + featureEvalSequence = {}; vi.mocked(evaluateFeatureRules).mockImplementation( ({ featureKey, context }) => { const evalFeature = evaluatedFeatures.find( (f) => f.feature.key === featureKey, )!; + if (featureEvalSequence[featureKey]) { + return { + value: evalFeature.config && { + key: evalFeature.config.key, + payload: evalFeature.config.payload, + }, + featureKey, + context: context, + ruleEvaluationResults: evalFeature.config?.ruleEvaluationResults, + missingContextFields: evalFeature.config?.missingContextFields, + }; + } + + featureEvalSequence[featureKey] = true; return { value: evalFeature.value, featureKey, @@ -940,7 +1030,6 @@ describe("BucketClient", () => { }); it("returns a feature", async () => { - // test that the feature is returned await client.initialize(); const feature = client.getFeature( { @@ -951,9 +1040,13 @@ describe("BucketClient", () => { "feature1", ); - expect(feature).toEqual({ + expect(feature).toStrictEqual({ key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, track: expect.any(Function), }); }); @@ -1072,6 +1165,49 @@ describe("BucketClient", () => { ); }); + it("`config` sends `check` event", async () => { + const context = { + company, + user, + other: otherContext, + }; + + // test that the feature is returned + await client.initialize(); + const feature = client.getFeature(context, "feature1"); + + // trigger `check` event + expect(feature.config).toBeDefined(); + + await client.flush(); + + expect(httpClient.post).toHaveBeenCalledWith( + BULK_ENDPOINT, + expectedHeaders, + [ + expect.objectContaining({ type: "company" }), + expect.objectContaining({ type: "user" }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate", + key: "feature1", + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate", + key: "feature2", + }), + { + type: "feature-flag-event", + action: "check", + evalResult: true, + targetingVersion: 1, + key: "feature1", + }, + ], + ); + }); + it("everything works for unknown features", async () => { const context: Context = { company, @@ -1136,6 +1272,7 @@ describe("BucketClient", () => { describe("getFeatures", () => { let client: BucketClient; + let featureEvalSequence: Record; beforeEach(async () => { httpClient.get.mockResolvedValue({ @@ -1149,12 +1286,27 @@ describe("BucketClient", () => { client = new BucketClient(validOptions); + featureEvalSequence = {}; vi.mocked(evaluateFeatureRules).mockImplementation( ({ featureKey, context }) => { const evalFeature = evaluatedFeatures.find( (f) => f.feature.key === featureKey, )!; + if (featureEvalSequence[featureKey]) { + return { + value: evalFeature.config && { + key: evalFeature.config.key, + payload: evalFeature.config.payload, + }, + featureKey, + context: context, + ruleEvaluationResults: evalFeature.config?.ruleEvaluationResults, + missingContextFields: evalFeature.config?.missingContextFields, + }; + } + + featureEvalSequence[featureKey] = true; return { value: evalFeature.value, featureKey, @@ -1184,22 +1336,29 @@ describe("BucketClient", () => { other: otherContext, }); - expect(result).toEqual({ + expect(result).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1288,22 +1447,29 @@ describe("BucketClient", () => { await client.initialize(); const features = client.getFeatures({ user }); - expect(features).toEqual({ + expect(features).toStrictEqual({ feature1: { isEnabled: true, key: "feature1", + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1358,22 +1524,29 @@ describe("BucketClient", () => { const features = client.getFeatures({ company }); // expect will trigger the `isEnabled` getter and send a `check` event - expect(features).toEqual({ + expect(features).toStrictEqual({ feature1: { isEnabled: true, key: "feature1", + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1428,22 +1601,29 @@ describe("BucketClient", () => { const features = client.getFeatures({ company, enableTracking: false }); // expect will trigger the `isEnabled` getter and send a `check` event - expect(features).toEqual({ + expect(features).toStrictEqual({ feature1: { isEnabled: true, key: "feature1", + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).not.toHaveBeenCalled(); }); @@ -1453,7 +1633,7 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(2); + expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); expect(httpClient.post).toHaveBeenCalledWith( @@ -1605,9 +1785,10 @@ describe("BucketClient", () => { "key", ); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "key", isEnabled: true, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }); @@ -1648,15 +1829,22 @@ describe("BucketClient", () => { expect.any(Error), ); - expect(features).toEqual({ + expect(features).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1674,10 +1862,16 @@ describe("BucketClient", () => { const result = client.getFeatures({}); // Trigger a feature check - expect(result.feature1).toEqual({ + expect(result.feature1).toStrictEqual({ key: "feature1", isEnabled: true, track: expect.any(Function), + config: { + key: "config-1", + payload: { + something: "else", + }, + }, }); await client.flush(); @@ -1697,20 +1891,34 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); client.featureOverrides = (_context: Context) => { - expect(context).toEqual(context); + expect(context).toStrictEqual(context); return { - feature1: false, + feature1: { isEnabled: false }, feature2: true, + feature3: { + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, + }, }; }; const features = client.getFeatures(context); @@ -1719,11 +1927,22 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: true, + config: { key: undefined, payload: undefined }, + track: expect.any(Function), + }, + feature3: { + key: "feature3", + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, track: expect.any(Function), }, }); @@ -1745,6 +1964,12 @@ describe("BucketClient", () => { key: "feature1", targetingVersion: 1, isEnabled: true, + config: { + key: "config-1", + version: 3, + default: true, + payload: { something: "else" }, + }, }, feature2: { key: "feature2", @@ -1768,15 +1993,20 @@ describe("BucketClient", () => { other: otherContext, }); - expect(result).toEqual({ + expect(result).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -1823,6 +2053,12 @@ describe("BucketClient", () => { key: "feature1", targetingVersion: 1, isEnabled: true, + config: { + key: "config-1", + version: 3, + default: true, + payload: { something: "else" }, + }, missingContextFields: ["one", "two"], }, }, @@ -1841,10 +2077,14 @@ describe("BucketClient", () => { other: otherContext, }); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "feature1", isEnabled: true, track: expect.any(Function), + config: { + key: "config-1", + payload: { something: "else" }, + }, }); expect(httpClient.get).toHaveBeenCalledTimes(1); @@ -2065,15 +2305,17 @@ describe("BoundBucketClient", () => { const result = await boundClient.getFeaturesRemote(); - expect(result).toEqual({ + expect(result).toStrictEqual({ feature1: { key: "feature1", isEnabled: true, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }, }); @@ -2095,9 +2337,10 @@ describe("BoundBucketClient", () => { const result = await boundClient.getFeatureRemote("feature1"); - expect(result).toEqual({ + expect(result).toStrictEqual({ key: "feature1", isEnabled: true, + config: { key: undefined, payload: undefined }, track: expect.any(Function), }); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 2e31670c..653cfe9a 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -8,8 +8,17 @@ describe("config tests", () => { expect(config).toEqual({ featureOverrides: { - myFeature: true, + myFeature: { + isEnabled: true, + }, myFeatureFalse: false, + myFeatureWithConfig: { + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, + }, }, secretKey: "mySecretKey", offline: true, @@ -27,10 +36,19 @@ describe("config tests", () => { const config = loadConfig("test/testConfig.json"); expect(config).toEqual({ featureOverrides: { - myFeature: true, + myFeature: { + isEnabled: true, + }, myFeatureFalse: false, myNewFeature: true, myNewFeatureFalse: false, + myFeatureWithConfig: { + isEnabled: true, + config: { + key: "config-1", + payload: { something: "else" }, + }, + }, }, secretKey: "mySecretKeyFromEnv", offline: true, diff --git a/packages/node-sdk/test/testConfig.json b/packages/node-sdk/test/testConfig.json index 311bf194..c6986a13 100644 --- a/packages/node-sdk/test/testConfig.json +++ b/packages/node-sdk/test/testConfig.json @@ -1,7 +1,14 @@ { "featureOverrides": { - "myFeature": true, - "myFeatureFalse": false + "myFeature": { "isEnabled": true }, + "myFeatureFalse": false, + "myFeatureWithConfig": { + "isEnabled": true, + "config": { + "key": "config-1", + "payload": { "something": "else" } + } + } }, "secretKey": "mySecretKey", "offline": true, diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 4cc5601b..9dfe3003 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -29,6 +29,10 @@ declare module "@bucketco/react-sdk" { interface Features { huddle: boolean; recordVideo: boolean; + questionnaire?: { + showAll: boolean; + time: 600000; + }; } } ``` @@ -64,7 +68,7 @@ import { BucketProvider } from "@bucketco/react-sdk"; ```tsx function LoadingBucket({ children }) { - const {isLoading} = useFeature("myFeature") + const { isLoading } = useFeature("myFeature") if (isLoading) { return } @@ -82,6 +86,62 @@ import { BucketProvider } from "@bucketco/react-sdk"; - `enableTracking` (default: `true`): Set to `false` to stop sending tracking events and user/company updates to Bucket. Useful when you're impersonating a user. +## Feature toggles + +Bucket determines which features are active for a given `user`/`company`. The `user`/`company` are given in the `BucketProvider` as props. + +If you supply `user` or `company` objects, they must include at least the `id` property otherwise they will be ignored in their entirety. +In addition to the `id`, you must also supply anything additional that you want to be able to evaluate feature targeting rules against. +The additional attributes are supplied using the `otherContext` prop. + +Attributes cannot be nested (multiple levels) and must be either strings, integers or booleans. + +- `name` is a special attribute and is used to display name for user/company +- for `user`, `email` is also special and will be highlighted in the Bucket UI if available + +```tsx + + + {/* children here are shown when loading finishes */} + + +``` + +To retrieve features along with their targeting information, use `useFeature(key: string)` hook (described in a section below). + +Note that accessing `isEnabled` on the object returned by `useFeature()` automatically +generates a `check` event. + +## Remote config + +Similar to `isEnabled`, each feature accessed using `useFeature()` hook, has a `config` property. This configuration +is managed from within Bucket. It is managed similar to the way access to features is managed, but instead of the +binary `isEnabled` you can have multiple configuration values which are given to different user/companies. + +```ts +const { + isEnabled, + config: { key, payload }, +} = useFeature("huddles"); + +// isEnabled: true, +// key: "gpt-3.5", +// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } +``` + +The `key` is always present while the `payload` is a optional JSON value for arbitrary configuration needs. +If feature has no configuration or, no configuration value was matched against the context, the `config` object +will be empty, thus, `key` will be `undefined`. Make sure to check against this case when trying to use the +configuration in your application. + +Note that, similar to `isEnabled`, accessing `config` on the object returned by `useFeature()` automatically +generates a `check` event. + ## Hooks ### `useFeature()` @@ -92,7 +152,13 @@ Returns the state of a given features for the current context. import { useFeature } from "@bucketco/react-sdk"; function StartHuddleButton() { - const { isLoading, isEnabled, track, requestFeedback } = useFeature("huddle"); + const { + isLoading, + isEnabled, + config: { key, payload }, + track, + requestFeedback, + } = useFeature("huddle"); if (isLoading) { return ; @@ -108,7 +174,7 @@ function StartHuddleButton() { ; + * } + * ``` + */ +export function useFeature< + TKey extends string, + FeatureDefs extends Record, +>(key: TKey): Feature & { isLoading: boolean } { + const { + features: { features, isLoading }, + client, + } = useContext(ProviderContext); + + const track = () => client?.track(key); + const requestFeedback = (opts: RequestFeedbackOptions) => + client?.requestFeedback({ ...opts, featureKey: key }); + + if (isLoading) { + return { + isLoading, + isEnabled: false, + config: { key: undefined, payload: undefined }, + track, + requestFeedback, + }; + } + + const feature = features[key]; + const enabled = feature?.isEnabledOverride ?? feature?.isEnabled ?? false; + + function sendCheckEvent() { + client + ?.sendCheckEvent({ + key, + value: enabled, + version: feature?.targetingVersion, + }) + .catch(() => { + // ignore + }); + } + + const reducedConfig = feature?.config + ? { key: feature.config.key, payload: feature.config.payload } + : { key: undefined, payload: undefined }; + + return { + isLoading, + track, + requestFeedback, + get isEnabled() { + sendCheckEvent(); + return enabled; + }, + get config() { + sendCheckEvent(); + return reducedConfig; + }, + }; +} + +/** + * Returns a function to send an event when a user performs an action + * Note: When calling `useTrack`, user/company must already be set. + * + * ```ts + * const track = useTrack(); + * track("Started Huddle", { button: "cta" }); + * ``` + */ +export function useTrack() { + const { client } = useContext(ProviderContext); + return (eventName: string, attributes?: Record | null) => + client?.track(eventName, attributes); +} + +/** + * Returns a function to open up the feedback form + * Note: When calling `useRequestFeedback`, user/company must already be set. + * + * See [link](../../browser-sdk/FEEDBACK.md#bucketclientrequestfeedback-options) for more information + * + * ```ts + * const requestFeedback = useRequestFeedback(); + * bucket.requestFeedback({ + * featureId: "bucket-feature-id", + * title: "How satisfied are you with file uploads?", + * }); + * ``` + */ +export function useRequestFeedback< + FeatureDefs extends Record = Record, + FeatureKey extends string = Extract, +>() { + const { client } = useContext(ProviderContext); + return ( + options: Omit & { + featureKey: FeatureKey; + }, + ) => client?.requestFeedback(options); +} + +/** + * Returns a function to manually send feedback collected from a user. + * Note: When calling `useSendFeedback`, user/company must already be set. + * + * See [link](./../../browser-sdk/FEEDBACK.md#using-your-own-ui-to-collect-feedback) for more information + * + * ```ts + * const sendFeedback = useSendFeedback(); + * sendFeedback({ + * featureId: "fe2323223";; + * question: "How did you like the new huddle feature?"; + * score: 5; + * comment: "I loved it!"; + * }); + * ``` + */ +export function useSendFeedback() { + const { client } = useContext(ProviderContext); + return (opts: UnassignedFeedback) => client?.feedback(opts); +} + +/** + * Returns a function to update the current user's information. + * For example, if the user changed role or opted into a beta-feature. + * + * The method returned is a function which returns a promise that + * resolves when after the features have been updated as a result + * of the user update. + * + * ```ts + * const updateUser = useUpdateUser(); + * updateUser({ optInHuddles: "true" }).then(() => console.log("Features updated")); + * ``` + */ +export function useUpdateUser() { + const { client } = useContext(ProviderContext); + return (opts: { [key: string]: string | number | undefined }) => + client?.updateUser(opts); +} + +/** + * Returns a function to update the current company's information. + * For example, if the company changed plan or opted into a beta-feature. + * + * The method returned is a function which returns a promise that + * resolves when after the features have been updated as a result + * of the company update. + * + * ```ts + * const updateCompany = useUpdateCompany(); + * updateCompany({ plan: "enterprise" }).then(() => console.log("Features updated")); + * ``` + */ +export function useUpdateCompany() { + const { client } = useContext(ProviderContext); + return (opts: { [key: string]: string | number | undefined }) => + client?.updateCompany(opts); +} + +/** + * Returns a function to update the "other" context information. + * For example, if the user changed workspace, you can set the workspace id here. + * + * The method returned is a function which returns a promise that + * resolves when after the features have been updated as a result + * of the update to the "other" context. + * + * ```ts + * const updateOtherContext = useUpdateOtherContext(); + * updateOtherContext({ workspaceId: newWorkspaceId }) + * .then(() => console.log("Features updated")); + * ``` + */ +export function useUpdateOtherContext() { + const { client } = useContext(ProviderContext); + return (opts: { [key: string]: string | number | undefined }) => + client?.updateOtherContext(opts); +} diff --git a/packages/react-sdk/src/BucketProvider.tsx b/packages/react-sdk/src/BucketProvider.tsx new file mode 100644 index 00000000..ce57d12c --- /dev/null +++ b/packages/react-sdk/src/BucketProvider.tsx @@ -0,0 +1,39 @@ +"use client"; + +import React from "react"; + +import { BucketClient } from "@bucketco/browser-sdk"; +import { + BucketClientConfigured, + FeatureDefs, + FeatureKey, +} from "@bucketco/browser-sdk/configuredClient"; + +import { + BaseBucketProvider, + BucketProps, + useFeature as baseUseFeature, +} from "./BaseBucketProvider"; + +export { + useUpdateCompany, + useUpdateOtherContext, + useUpdateUser, +} from "./BaseBucketProvider"; +export type { FeatureKey } from "@bucketco/browser-sdk/configuredClient"; + +export const BucketProvider = (props: BucketProps) => ( + props.newBucketClient!(opts) + : // BucketClientConfigured will come with the "features" already loaded in + (opts) => new BucketClientConfigured(opts) as BucketClient + } + /> +); + +export function useFeature(key: Key) { + return baseUseFeature(key); +} diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 6fffad73..cc998a43 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -1,340 +1,12 @@ "use client"; -import React, { - createContext, - ReactNode, - useContext, - useEffect, - useRef, - useState, -} from "react"; -import canonicalJSON from "canonical-json"; - -import { - BucketClient, - BucketContext, - FeaturesOptions, - FeedbackOptions, - RawFeatures, - RequestFeedbackData, - ToolbarOptions, - UnassignedFeedback, -} from "@bucketco/browser-sdk"; - -import { version } from "../package.json"; - -export interface Features {} - -const SDK_VERSION = `react-sdk/${version}`; - -export type FeatureKey = keyof (keyof Features extends never - ? Record - : Features); - -type ProviderContextType = { - client?: BucketClient; - features: { - features: RawFeatures; - isLoading: boolean; - }; -}; - -const ProviderContext = createContext({ - features: { - features: {}, - isLoading: false, - }, -}); - -export type BucketProps = BucketContext & { - publishableKey: string; - featureOptions?: Omit & { - fallbackFeatures?: FeatureKey[]; - }; - children?: ReactNode; - loadingComponent?: ReactNode; - feedback?: FeedbackOptions; - /** - * @deprecated - * Use `apiBaseUrl` instead. - */ - host?: string; - apiBaseUrl?: string; - appBaseUrl?: string; - - /** - * @deprecated - * Use `sseBaseUrl` instead. - */ - sseHost?: string; - sseBaseUrl?: string; - debug?: boolean; - enableTracking?: boolean; - - featureList?: Readonly; - - toolbar?: ToolbarOptions; - - // for testing - newBucketClient?: ( - ...args: ConstructorParameters - ) => BucketClient; -}; - -export function BucketProvider({ - children, - user, - company, - otherContext, - publishableKey, - featureOptions, - loadingComponent, - featureList, - newBucketClient = (...args) => new BucketClient(...args), - ...config -}: BucketProps) { - const [featuresLoading, setFeaturesLoading] = useState(true); - const [features, setFeatures] = useState({}); - - const clientRef = useRef(); - const contextKeyRef = useRef(); - - const featureContext = { user, company, otherContext }; - const contextKey = canonicalJSON({ config, featureContext }); - - useEffect(() => { - // useEffect will run twice in development mode - // This is a workaround to prevent re-initialization - if (contextKeyRef.current === contextKey) { - return; - } - contextKeyRef.current = contextKey; - - // on update of contextKey and on unmount - if (clientRef.current) { - void clientRef.current.stop(); - } - - const client = newBucketClient({ - publishableKey, - user, - company, - otherContext, - - host: config.host, - apiBaseUrl: config.apiBaseUrl, - appBaseUrl: config.appBaseUrl, - sseHost: config.sseHost, - sseBaseUrl: config.sseBaseUrl, - - enableTracking: config.enableTracking, - - features: { - ...featureOptions, - }, - feedback: config.feedback, - logger: config.debug ? console : undefined, - sdkVersion: SDK_VERSION, - featureList, - }); - clientRef.current = client; - - client.onFeaturesUpdated(() => { - setFeatures(client.getFeatures()); - }); - - client - .initialize() - .catch((e) => { - client.logger.error("failed to initialize client", e); - }) - .finally(() => { - setFeaturesLoading(false); - }); - }, [contextKey]); - - const context: ProviderContextType = { - features: { - features: features, - isLoading: featuresLoading, - }, - client: clientRef.current, - }; - return ( - - ); -} - -/** - * Returns the state of a given feature for the current context, e.g. - * - * ```ts - * function HuddleButton() { - * const {isEnabled, track} = useFeature("huddle"); - * if (isEnabled) { - * return ; - * } - * } - * ``` - */ -export function useFeature(key: FeatureKey) { - const { - features: { features, isLoading }, - client, - } = useContext(ProviderContext); - - const track = () => client?.track(key); - const requestFeedback = ( - opts: Omit, - ) => client?.requestFeedback({ ...opts, featureKey: key }); - - if (isLoading) { - return { - isLoading, - isEnabled: false, - track, - requestFeedback, - }; - } - - const feature = features[key]; - const enabled = feature?.isEnabledOverride ?? feature?.isEnabled ?? false; - - return { - isLoading, - track, - requestFeedback, - get isEnabled() { - client - ?.sendCheckEvent({ - key, - value: enabled, - version: feature?.targetingVersion, - }) - .catch(() => { - // ignore - }); - return enabled; - }, - }; -} - -/** - * Returns a function to send an event when a user performs an action - * Note: When calling `useTrack`, user/company must already be set. - * - * ```ts - * const track = useTrack(); - * track("Started Huddle", { button: "cta" }); - * ``` - */ -export function useTrack() { - const { client } = useContext(ProviderContext); - return (eventName: string, attributes?: Record | null) => - client?.track(eventName, attributes); -} - -/** - * Returns a function to open up the feedback form - * Note: When calling `useRequestFeedback`, user/company must already be set. - * - * See [link](../../browser-sdk/FEEDBACK.md#bucketclientrequestfeedback-options) for more information - * - * ```ts - * const requestFeedback = useRequestFeedback(); - * bucket.requestFeedback({ - * featureId: "bucket-feature-id", - * title: "How satisfied are you with file uploads?", - * }); - * ``` - */ -export function useRequestFeedback() { - const { client } = useContext(ProviderContext); - return (options: RequestFeedbackData) => client?.requestFeedback(options); -} - -/** - * Returns a function to manually send feedback collected from a user. - * Note: When calling `useSendFeedback`, user/company must already be set. - * - * See [link](./../../browser-sdk/FEEDBACK.md#using-your-own-ui-to-collect-feedback) for more information - * - * ```ts - * const sendFeedback = useSendFeedback(); - * sendFeedback({ - * featureId: "fe2323223";; - * question: "How did you like the new huddle feature?"; - * score: 5; - * comment: "I loved it!"; - * }); - * ``` - */ -export function useSendFeedback() { - const { client } = useContext(ProviderContext); - return (opts: UnassignedFeedback) => client?.feedback(opts); -} - -/** - * Returns a function to update the current user's information. - * For example, if the user changed role or opted into a beta-feature. - * - * The method returned is a function which returns a promise that - * resolves when after the features have been updated as a result - * of the user update. - * - * ```ts - * const updateUser = useUpdateUser(); - * updateUser({ optInHuddles: "true" }).then(() => console.log("Features updated")); - * ``` - */ -export function useUpdateUser() { - const { client } = useContext(ProviderContext); - return (opts: { [key: string]: string | number | undefined }) => - client?.updateUser(opts); -} - -/** - * Returns a function to update the current company's information. - * For example, if the company changed plan or opted into a beta-feature. - * - * The method returned is a function which returns a promise that - * resolves when after the features have been updated as a result - * of the company update. - * - * ```ts - * const updateCompany = useUpdateCompany(); - * updateCompany({ plan: "enterprise" }).then(() => console.log("Features updated")); - * ``` - */ -export function useUpdateCompany() { - const { client } = useContext(ProviderContext); - return (opts: { [key: string]: string | number | undefined }) => - client?.updateCompany(opts); -} - -/** - * Returns a function to update the "other" context information. - * For example, if the user changed workspace, you can set the workspace id here. - * - * The method returned is a function which returns a promise that - * resolves when after the features have been updated as a result - * of the update to the "other" context. - * - * ```ts - * const updateOtherContext = useUpdateOtherContext(); - * updateOtherContext({ workspaceId: newWorkspaceId }) - * .then(() => console.log("Features updated")); - * ``` - */ -export function useUpdateOtherContext() { - const { client } = useContext(ProviderContext); - return (opts: { [key: string]: string | number | undefined }) => - client?.updateOtherContext(opts); -} +export type { BucketProps } from "./BaseBucketProvider"; +export { BaseBucketProvider } from "./BaseBucketProvider"; +export type { FeatureKey } from "./BucketProvider"; +export { + BucketProvider, + useFeature, + useUpdateCompany, + useUpdateOtherContext, + useUpdateUser, +} from "./BucketProvider"; diff --git a/packages/react-sdk/src/types.d.ts b/packages/react-sdk/src/types.d.ts index 59f438a8..59813f1c 100644 --- a/packages/react-sdk/src/types.d.ts +++ b/packages/react-sdk/src/types.d.ts @@ -1 +1,7 @@ declare module "canonical-json"; + +declare module "_bucket" { + import { FeatureDef } from "@bucketco/browser-sdk"; + + export type GeneratedFeatureTypes = Record; +} diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 97a705d8..d53804c3 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -81,6 +81,11 @@ const server = setupServer( key: "abc", isEnabled: true, targetingVersion: 1, + config: { + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + version: 2, + }, }, def: { key: "def", @@ -187,6 +192,7 @@ describe("", () => { logger: undefined, sseBaseUrl: "https://test.com", sseHost: undefined, + toolbar: undefined, enableTracking: false, feedback: undefined, featureList: undefined, @@ -221,6 +227,7 @@ describe("useFeature", () => { expect(result.current).toStrictEqual({ isEnabled: false, isLoading: true, + config: { key: undefined, payload: undefined }, track: expect.any(Function), requestFeedback: expect.any(Function), }); @@ -235,6 +242,7 @@ describe("useFeature", () => { await waitFor(() => { expect(result.current).toStrictEqual({ + config: { key: undefined, payload: undefined }, isEnabled: false, isLoading: false, track: expect.any(Function), @@ -244,6 +252,27 @@ describe("useFeature", () => { unmount(); }); + + test("provides the expected values if feature is enabled", async () => { + const { result, unmount } = renderHook(() => useFeature("abc"), { + wrapper: ({ children }) => getProvider({ children }), + }); + + await waitFor(() => { + expect(result.current).toStrictEqual({ + isEnabled: true, + isLoading: false, + config: { + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + }, + track: expect.any(Function), + requestFeedback: expect.any(Function), + }); + }); + + unmount(); + }); }); describe("useTrack", () => { diff --git a/packages/react-sdk/tsconfig.json b/packages/react-sdk/tsconfig.json index 4edc154c..f740bc6e 100644 --- a/packages/react-sdk/tsconfig.json +++ b/packages/react-sdk/tsconfig.json @@ -5,8 +5,11 @@ "outDir": "./dist/", "declarationDir": "./dist/types", "jsx": "react", - "declarationMap": true + "declarationMap": true, + "moduleResolution": "bundler", + "module": "Preserve", + "rootDir": "./src" }, - "include": ["src", "dev"], + "include": ["src"], "typeRoots": ["./node_modules/@types"] } diff --git a/packages/react-sdk/vite.config.mjs b/packages/react-sdk/vite.config.mjs index 9f73e246..8280f32a 100644 --- a/packages/react-sdk/vite.config.mjs +++ b/packages/react-sdk/vite.config.mjs @@ -18,16 +18,18 @@ export default defineConfig({ exclude: ["**/node_modules/**", "test/e2e/**", "dev"], sourcemap: true, lib: { - entry: resolve(__dirname, "src/index.tsx"), - name: "BucketReactSDK", - fileName: "bucket-react-sdk", - formats: ["es", "umd"], + entry: { + index: resolve(__dirname, "src/index.tsx"), + baseBucketProvider: resolve(__dirname, "src/BaseBucketProvider.tsx"), + }, + formats: ["es"], }, rollupOptions: { - external: ["react", "react-dom"], + external: ["react", "react-dom", "_bucket"], output: { globals: { react: "React", + _bucket: "BucketInternal", }, }, }, diff --git a/yarn.lock b/yarn.lock index 0811d8ae..39cec41b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -894,19 +894,7 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:3.0.0-alpha.0": - version: 3.0.0-alpha.0 - resolution: "@bucketco/browser-sdk@npm:3.0.0-alpha.0" - dependencies: - "@floating-ui/dom": "npm:^1.6.8" - canonical-json: "npm:^0.0.4" - js-cookie: "npm:^3.0.5" - preact: "npm:^10.22.1" - checksum: 10c0/eba1a2f15c49c1aeca37c6d2b218e50d92e2034c51a9acdd6884ce143156dc7f285f9141f5e6cfcc2aed0186c0b8fc61bb9939aca737a1e76d1a2d9d65a7b74c - languageName: node - linkType: hard - -"@bucketco/browser-sdk@workspace:packages/browser-sdk": +"@bucketco/browser-sdk@npm:3.0.0-alpha.3, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -938,6 +926,33 @@ __metadata: languageName: unknown linkType: soft +"@bucketco/cli@workspace:packages/cli": + version: 0.0.0-use.local + resolution: "@bucketco/cli@workspace:packages/cli" + dependencies: + "@bucketco/eslint-config": "workspace:^" + "@bucketco/tsconfig": "workspace:^" + "@inquirer/prompts": "npm:^7.2.3" + "@sindresorhus/slugify": "npm:^2.2.1" + "@types/fs-extra": "npm:^11.0.4" + "@types/node": "npm:^22.5.1" + axios: "npm:^1.7.5" + chalk: "npm:^5.3.0" + commander: "npm:^12.1.0" + find-up: "npm:^7.0.0" + fs-extra: "npm:^11.2.0" + open: "npm:^10.1.0" + ora: "npm:^8.1.0" + strip-json-comments: "npm:^5.0.1" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.5.4" + vitest: "npm:^2.1.8" + zod: "npm:^3.24.1" + bin: + bucket: ./dist/index.js + languageName: unknown + linkType: soft + "@bucketco/eslint-config@npm:0.0.2, @bucketco/eslint-config@npm:~0.0.2, @bucketco/eslint-config@workspace:^, @bucketco/eslint-config@workspace:packages/eslint-config": version: 0.0.0-use.local resolution: "@bucketco/eslint-config@workspace:packages/eslint-config" @@ -1042,7 +1057,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/react-sdk@workspace:packages/react-sdk" dependencies: - "@bucketco/browser-sdk": "npm:3.0.0-alpha.0" + "@bucketco/browser-sdk": "npm:3.0.0-alpha.3" "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@testing-library/react": "npm:^15.0.7" @@ -1897,6 +1912,21 @@ __metadata: languageName: node linkType: hard +"@inquirer/checkbox@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/checkbox@npm:4.0.6" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/919e3c5d652f1ccd9d5e8e9678e63981a968ba4a0dffe9d9409d94a1951b398218f7dfb05e57aefcb3c3c1d61ac2333160e370b0ff4632ada7a92ebe07a2ca72 + languageName: node + linkType: hard + "@inquirer/confirm@npm:^3.0.0": version: 3.1.17 resolution: "@inquirer/confirm@npm:3.1.17" @@ -1907,6 +1937,35 @@ __metadata: languageName: node linkType: hard +"@inquirer/confirm@npm:^5.1.3": + version: 5.1.3 + resolution: "@inquirer/confirm@npm:5.1.3" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/ddbca429ebb3a8bf1d10928f4ab0c8eedbf3f74f85ed64c6b26a830f0fbbab5fa964b9ef2eb2c57a10b9afc9ca3921a12e4659f5a83069078cd1a7ce3d0d126d + languageName: node + linkType: hard + +"@inquirer/core@npm:^10.1.4": + version: 10.1.4 + resolution: "@inquirer/core@npm:10.1.4" + dependencies: + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" + ansi-escapes: "npm:^4.3.2" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^2.0.0" + signal-exit: "npm:^4.1.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/4e6c51713c79a0b22381a08a2d11c37f2d696597d01bdecd7b3482889e53e4ac279c55d663a365798ad52becc37052b571bc3ec85ee8a10054c681d9248b88d3 + languageName: node + linkType: hard + "@inquirer/core@npm:^9.0.5": version: 9.0.5 resolution: "@inquirer/core@npm:9.0.5" @@ -1928,6 +1987,32 @@ __metadata: languageName: node linkType: hard +"@inquirer/editor@npm:^4.2.3": + version: 4.2.3 + resolution: "@inquirer/editor@npm:4.2.3" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + external-editor: "npm:^3.1.0" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/0194a660b33e38781c35a6ab531f76beca998e5e30ebc90bb94e2140fd943e0dfcff4f9c650f4f79f74df7dac04c82db254ff8c2d9ef0b55c491523f859a8c2b + languageName: node + linkType: hard + +"@inquirer/expand@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/expand@npm:4.0.6" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + yoctocolors-cjs: "npm:^2.1.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/2a4990744edf17528c5cf894b9a7a04f202740fb9e2123fb8ced1e623f5bf9716976b037e1b23e88cfce826a85b6052d49784ac2294644e353767b51a0a2f877 + languageName: node + linkType: hard + "@inquirer/figures@npm:^1.0.5": version: 1.0.5 resolution: "@inquirer/figures@npm:1.0.5" @@ -1935,6 +2020,112 @@ __metadata: languageName: node linkType: hard +"@inquirer/figures@npm:^1.0.9": + version: 1.0.9 + resolution: "@inquirer/figures@npm:1.0.9" + checksum: 10c0/21e1a7c902b2b77f126617b501e0fe0d703fae680a9df472afdae18a3e079756aee85690cef595a14e91d18630118f4a3893aab6832b9232fefc6ab31c804a68 + languageName: node + linkType: hard + +"@inquirer/input@npm:^4.1.3": + version: 4.1.3 + resolution: "@inquirer/input@npm:4.1.3" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/251468b9596fcbff286d0817da7408f2a78230c1f84de23361e6362a8a91e5bf4c42c04f4971a8fe751eb0afc4ab1cef0d3766742fd4e693b4b0cbcc72aa8d97 + languageName: node + linkType: hard + +"@inquirer/number@npm:^3.0.6": + version: 3.0.6 + resolution: "@inquirer/number@npm:3.0.6" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/26c030735bdc94053dfca50db1e75c7e325b8dcc009f3f9e6f572d89b67d7b23cfb3920ed2fa6fa34c312b5ebb6b86ba5b4e77c277ce463720eba45052c0d253 + languageName: node + linkType: hard + +"@inquirer/password@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/password@npm:4.0.6" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + ansi-escapes: "npm:^4.3.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/c36f675d350c38156efe255d9b3a052271faff2bfcebf626f5f02092e9110ef8f87a6985e96dd0c2351fa79723d0847bacaa86ae10c1d24526434de96af5503e + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^7.2.3": + version: 7.2.3 + resolution: "@inquirer/prompts@npm:7.2.3" + dependencies: + "@inquirer/checkbox": "npm:^4.0.6" + "@inquirer/confirm": "npm:^5.1.3" + "@inquirer/editor": "npm:^4.2.3" + "@inquirer/expand": "npm:^4.0.6" + "@inquirer/input": "npm:^4.1.3" + "@inquirer/number": "npm:^3.0.6" + "@inquirer/password": "npm:^4.0.6" + "@inquirer/rawlist": "npm:^4.0.6" + "@inquirer/search": "npm:^3.0.6" + "@inquirer/select": "npm:^4.0.6" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/52c2e1fd8a4e98bc5fd6a79acf9c7d7e1ecc534013d7c018412a6ed34d15be69a2d10791b539740fed8e538485e359e1cacbec98ca3d04e24c5e9fa9480d7bc6 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/rawlist@npm:4.0.6" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + yoctocolors-cjs: "npm:^2.1.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/c79f0ddd5cf7eae8db27a7080e277c32809d7bd58619f470d8b1598d1aff36f6aac276535ef35801a1dae97bb3763fd248e1067800e6eccd49276206d6cdb945 + languageName: node + linkType: hard + +"@inquirer/search@npm:^3.0.6": + version: 3.0.6 + resolution: "@inquirer/search@npm:3.0.6" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" + yoctocolors-cjs: "npm:^2.1.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/27afe9105b9fd26b5985847f75c82f59156158b6366e35896764cd08ee7bb76e3d9c7110c6ed50ab4d7e13466ea3f0e60492a644e0eb6a0d8c30701b07221ad9 + languageName: node + linkType: hard + +"@inquirer/select@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/select@npm:4.0.6" + dependencies: + "@inquirer/core": "npm:^10.1.4" + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/5346007a5f62ff88f36c219b625d6031ba12fea6849b38aab1d3ed1219387004bf1c3a44aeec47a3988c9aeb1934a8a06509fe9e00f39135fa22113a01e1cc37 + languageName: node + linkType: hard + "@inquirer/type@npm:^1.5.1": version: 1.5.1 resolution: "@inquirer/type@npm:1.5.1" @@ -1944,6 +2135,15 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^3.0.2": + version: 3.0.2 + resolution: "@inquirer/type@npm:3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/fe348db2977fff92cad0ade05b36ec40714326fccd4a174be31663f8923729b4276f1736d892a449627d7fb03235ff44e8aac5aa72b09036d993593b813ef313 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -2034,6 +2234,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.0": + version: 1.5.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" + checksum: 10c0/2eb864f276eb1096c3c11da3e9bb518f6d9fc0023c78344cdc037abadc725172c70314bdb360f2d4b7bffec7f5d657ce006816bc5d4ecb35e61b66132db00c18 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:0.3.9": version: 0.3.9 resolution: "@jridgewell/trace-mapping@npm:0.3.9" @@ -3600,6 +3807,25 @@ __metadata: languageName: node linkType: hard +"@sindresorhus/slugify@npm:^2.2.1": + version: 2.2.1 + resolution: "@sindresorhus/slugify@npm:2.2.1" + dependencies: + "@sindresorhus/transliterate": "npm:^1.0.0" + escape-string-regexp: "npm:^5.0.0" + checksum: 10c0/c3fe41d917347f0e2a1e25a48225afffde8ef379a26217e749d5267e965f564c6a555fa17475b637d6fd84645f42e1e4b530477b57110fa80428024a0fadba25 + languageName: node + linkType: hard + +"@sindresorhus/transliterate@npm:^1.0.0": + version: 1.6.0 + resolution: "@sindresorhus/transliterate@npm:1.6.0" + dependencies: + escape-string-regexp: "npm:^5.0.0" + checksum: 10c0/c5552abd98eb4ab3a8653ccb7addf24e0b6f2aa2a4c420689033f8c9d292abb2222fc08e330adf4055580ac78fe810b7467ed012cdf38f4d64175c42571b8b15 + languageName: node + linkType: hard + "@swc/counter@npm:^0.1.3": version: 0.1.3 resolution: "@swc/counter@npm:0.1.3" @@ -3762,6 +3988,16 @@ __metadata: languageName: node linkType: hard +"@types/fs-extra@npm:^11.0.4": + version: 11.0.4 + resolution: "@types/fs-extra@npm:11.0.4" + dependencies: + "@types/jsonfile": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/9e34f9b24ea464f3c0b18c3f8a82aefc36dc524cc720fc2b886e5465abc66486ff4e439ea3fb2c0acebf91f6d3f74e514f9983b1f02d4243706bdbb7511796ad + languageName: node + linkType: hard + "@types/hast@npm:^3.0.4": version: 3.0.4 resolution: "@types/hast@npm:3.0.4" @@ -3810,6 +4046,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonfile@npm:*": + version: 6.1.4 + resolution: "@types/jsonfile@npm:6.1.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/b12d068b021e4078f6ac4441353965769be87acf15326173e2aea9f3bf8ead41bd0ad29421df5bbeb0123ec3fc02eb0a734481d52903704a1454a1845896b9eb + languageName: node + linkType: hard + "@types/minimatch@npm:^3.0.3": version: 3.0.5 resolution: "@types/minimatch@npm:3.0.5" @@ -3858,6 +4103,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.5.1": + version: 22.5.1 + resolution: "@types/node@npm:22.5.1" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/35373176d8a1d4e16004a1ed303e68d39e4c6341024dc056f2577982df98c1a045a6b677f12ed557796f09bbf7d621f428f6874cc37ed28f7b336fa604b5f6a6 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -4210,6 +4464,37 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/expect@npm:2.1.8" + dependencies: + "@vitest/spy": "npm:2.1.8" + "@vitest/utils": "npm:2.1.8" + chai: "npm:^5.1.2" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/6fbf4abc2360efe4d3671d3425f8bb6012fe2dd932a88720d8b793030b766ba260494822c721d3fc497afe52373515c7e150635a95c25f6e1b567f86155c5408 + languageName: node + linkType: hard + +"@vitest/mocker@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/mocker@npm:2.1.8" + dependencies: + "@vitest/spy": "npm:2.1.8" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.12" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/b4113ed8a57c0f60101d02e1b1769357a346ecd55ded499eab384d52106fd4b12d51e9aaa6db98f47de0d56662477be0ed8d46d6dfa84c235f9e1b234709814e + languageName: node + linkType: hard + "@vitest/pretty-format@npm:2.0.4, @vitest/pretty-format@npm:^2.0.4": version: 2.0.4 resolution: "@vitest/pretty-format@npm:2.0.4" @@ -4228,6 +4513,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:2.1.8, @vitest/pretty-format@npm:^2.1.8": + version: 2.1.8 + resolution: "@vitest/pretty-format@npm:2.1.8" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/1dc5c9b1c7c7e78e46a2a16033b6b20be05958bbebc5a5b78f29e32718c80252034804fccd23f34db6b3583239db47e68fc5a8e41942c54b8047cc3b4133a052 + languageName: node + linkType: hard + "@vitest/runner@npm:1.6.0": version: 1.6.0 resolution: "@vitest/runner@npm:1.6.0" @@ -4259,6 +4553,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/runner@npm:2.1.8" + dependencies: + "@vitest/utils": "npm:2.1.8" + pathe: "npm:^1.1.2" + checksum: 10c0/d0826a71494adeafc8c6478257f584d11655145c83e2d8f94c17301d7059c7463ad768a69379e394c50838a7435abcc9255a6b7d8894f5ee06b153e314683a75 + languageName: node + linkType: hard + "@vitest/snapshot@npm:1.6.0": version: 1.6.0 resolution: "@vitest/snapshot@npm:1.6.0" @@ -4292,6 +4596,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/snapshot@npm:2.1.8" + dependencies: + "@vitest/pretty-format": "npm:2.1.8" + magic-string: "npm:^0.30.12" + pathe: "npm:^1.1.2" + checksum: 10c0/8d7a77a52e128630ea737ee0a0fe746d1d325cac5848326861dbf042844da4d5c1a5145539ae0ed1a3f0b0363506e98d86f2679fadf114ec4b987f1eb616867b + languageName: node + linkType: hard + "@vitest/spy@npm:1.6.0": version: 1.6.0 resolution: "@vitest/spy@npm:1.6.0" @@ -4319,6 +4634,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/spy@npm:2.1.8" + dependencies: + tinyspy: "npm:^3.0.2" + checksum: 10c0/9740f10772ede004ea7f9ffb8a6c3011341d75d9d7f2d4d181b123a701c4691e942f38cf1700684a3bb5eea3c78addf753fd8cdf78c51d8eadc3bada6fadf8f2 + languageName: node + linkType: hard + "@vitest/utils@npm:1.6.0": version: 1.6.0 resolution: "@vitest/utils@npm:1.6.0" @@ -4355,6 +4679,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/utils@npm:2.1.8" + dependencies: + "@vitest/pretty-format": "npm:2.1.8" + loupe: "npm:^3.1.2" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/d4a29ecd8f6c24c790e4c009f313a044d89e664e331bc9c3cfb57fe1380fb1d2999706dbbfc291f067d6c489602e76d00435309fbc906197c0d01f831ca17d64 + languageName: node + linkType: hard + "@volar/language-core@npm:1.11.1, @volar/language-core@npm:~1.11.1": version: 1.11.1 resolution: "@volar/language-core@npm:1.11.1" @@ -5419,6 +5754,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.7.5": + version: 1.7.5 + resolution: "axios@npm:1.7.5" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/1d5daeb28b3d1bb2a7b9f0743433c4bfbeaddc15461e50ebde487eec6c009af2515749d5261096dd430c90cd891bd310bcba5ec3967bae2033c4a307f58a6ad3 + languageName: node + linkType: hard + "axobject-query@npm:~3.1.1": version: 3.1.1 resolution: "axobject-query@npm:3.1.1" @@ -5590,6 +5936,15 @@ __metadata: languageName: node linkType: hard +"bundle-name@npm:^4.1.0": + version: 4.1.0 + resolution: "bundle-name@npm:4.1.0" + dependencies: + run-applescript: "npm:^7.0.0" + checksum: 10c0/8e575981e79c2bcf14d8b1c027a3775c095d362d1382312f444a7c861b0e21513c0bd8db5bd2b16e50ba0709fa622d4eab6b53192d222120305e68359daece29 + languageName: node + linkType: hard + "busboy@npm:1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -5822,6 +6177,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.1.2": + version: 5.1.2 + resolution: "chai@npm:5.1.2" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/6c04ff8495b6e535df9c1b062b6b094828454e9a3c9493393e55b2f4dbff7aa2a29a4645133cad160fb00a16196c4dc03dc9bb37e1f4ba9df3b5f50d7533a736 + languageName: node + linkType: hard + "chalk@npm:4.1.0": version: 4.1.0 resolution: "chalk@npm:4.1.0" @@ -5853,6 +6221,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^5.3.0": + version: 5.3.0 + resolution: "chalk@npm:5.3.0" + checksum: 10c0/8297d436b2c0f95801103ff2ef67268d362021b8210daf8ddbe349695333eb3610a71122172ff3b0272f1ef2cf7cc2c41fdaa4715f52e49ffe04c56340feed09 + languageName: node + linkType: hard + "chardet@npm:^0.7.0": version: 0.7.0 resolution: "chardet@npm:0.7.0" @@ -5932,6 +6307,15 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^5.0.0": + version: 5.0.0 + resolution: "cli-cursor@npm:5.0.0" + dependencies: + restore-cursor: "npm:^5.0.0" + checksum: 10c0/7ec62f69b79f6734ab209a3e4dbdc8af7422d44d360a7cb1efa8a0887bbe466a6e625650c466fe4359aee44dbe2dc0b6994b583d40a05d0808a5cb193641d220 + languageName: node + linkType: hard + "cli-spinners@npm:2.6.1": version: 2.6.1 resolution: "cli-spinners@npm:2.6.1" @@ -6095,6 +6479,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^12.1.0": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 10c0/6e1996680c083b3b897bfc1cfe1c58dfbcd9842fd43e1aaf8a795fbc237f65efcc860a3ef457b318e73f29a4f4a28f6403c3d653d021d960e4632dd45bde54a9 + languageName: node + linkType: hard + "commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -6576,6 +6967,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.7": + version: 4.4.0 + resolution: "debug@npm:4.4.0" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de + languageName: node + linkType: hard + "decamelize-keys@npm:^1.1.0": version: 1.1.1 resolution: "decamelize-keys@npm:1.1.1" @@ -6656,6 +7059,23 @@ __metadata: languageName: node linkType: hard +"default-browser-id@npm:^5.0.0": + version: 5.0.0 + resolution: "default-browser-id@npm:5.0.0" + checksum: 10c0/957fb886502594c8e645e812dfe93dba30ed82e8460d20ce39c53c5b0f3e2afb6ceaec2249083b90bdfbb4cb0f34e1f73fde3d68cac00becdbcfd894156b5ead + languageName: node + linkType: hard + +"default-browser@npm:^5.2.1": + version: 5.2.1 + resolution: "default-browser@npm:5.2.1" + dependencies: + bundle-name: "npm:^4.1.0" + default-browser-id: "npm:^5.0.0" + checksum: 10c0/73f17dc3c58026c55bb5538749597db31f9561c0193cd98604144b704a981c95a466f8ecc3c2db63d8bfd04fb0d426904834cfc91ae510c6aeb97e13c5167c4d + languageName: node + linkType: hard + "defaults@npm:^1.0.3": version: 1.0.4 resolution: "defaults@npm:1.0.4" @@ -6694,6 +7114,13 @@ __metadata: languageName: node linkType: hard +"define-lazy-prop@npm:^3.0.0": + version: 3.0.0 + resolution: "define-lazy-prop@npm:3.0.0" + checksum: 10c0/5ab0b2bf3fa58b3a443140bbd4cd3db1f91b985cc8a246d330b9ac3fc0b6a325a6d82bddc0b055123d745b3f9931afeea74a5ec545439a1630b9c8512b0eeb49 + languageName: node + linkType: hard + "define-properties@npm:^1.1.3, define-properties@npm:^1.1.4, define-properties@npm:^1.2.0": version: 1.2.0 resolution: "define-properties@npm:1.2.0" @@ -6912,6 +7339,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^10.3.0": + version: 10.4.0 + resolution: "emoji-regex@npm:10.4.0" + checksum: 10c0/a3fcedfc58bfcce21a05a5f36a529d81e88d602100145fcca3dc6f795e3c8acc4fc18fe773fbf9b6d6e9371205edb3afa2668ec3473fa2aa7fd47d2a9d46482d + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -7185,6 +7619,13 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^1.5.4": + version: 1.6.0 + resolution: "es-module-lexer@npm:1.6.0" + checksum: 10c0/667309454411c0b95c476025929881e71400d74a746ffa1ff4cb450bd87f8e33e8eef7854d68e401895039ac0bac64e7809acbebb6253e055dd49ea9e3ea9212 + languageName: node + linkType: hard + "es-object-atoms@npm:^1.0.0": version: 1.0.0 resolution: "es-object-atoms@npm:1.0.0" @@ -7346,6 +7787,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:^5.0.0": + version: 5.0.0 + resolution: "escape-string-regexp@npm:5.0.0" + checksum: 10c0/6366f474c6f37a802800a435232395e04e9885919873e382b157ab7e8f0feb8fed71497f84a6f6a81a49aab41815522f5839112bd38026d203aea0c91622df95 + languageName: node + linkType: hard + "eslint-config-next@npm:14.2.5": version: 14.2.5 resolution: "eslint-config-next@npm:14.2.5" @@ -7790,6 +8238,13 @@ __metadata: languageName: node linkType: hard +"expect-type@npm:^1.1.0": + version: 1.1.0 + resolution: "expect-type@npm:1.1.0" + checksum: 10c0/5af0febbe8fe18da05a6d51e3677adafd75213512285408156b368ca471252565d5ca6e59e4bddab25121f3cfcbbebc6a5489f8cc9db131cc29e69dcdcc7ae15 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -7797,7 +8252,7 @@ __metadata: languageName: node linkType: hard -"external-editor@npm:^3.0.3": +"external-editor@npm:^3.0.3, external-editor@npm:^3.1.0": version: 3.1.0 resolution: "external-editor@npm:3.1.0" dependencies: @@ -7943,6 +8398,17 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^7.0.0": + version: 7.0.0 + resolution: "find-up@npm:7.0.0" + dependencies: + locate-path: "npm:^7.2.0" + path-exists: "npm:^5.0.0" + unicorn-magic: "npm:^0.1.0" + checksum: 10c0/e6ee3e6154560bc0ab3bc3b7d1348b31513f9bdf49a5dd2e952495427d559fa48cdf33953e85a309a323898b43fa1bfbc8b80c880dfc16068384783034030008 + languageName: node + linkType: hard + "flat-cache@npm:^3.0.4": version: 3.0.4 resolution: "flat-cache@npm:3.0.4" @@ -8030,7 +8496,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^11.1.0, fs-extra@npm:^11.1.1": +"fs-extra@npm:^11.1.0, fs-extra@npm:^11.1.1, fs-extra@npm:^11.2.0": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" dependencies: @@ -8178,6 +8644,13 @@ __metadata: languageName: node linkType: hard +"get-east-asian-width@npm:^1.0.0": + version: 1.2.0 + resolution: "get-east-asian-width@npm:1.2.0" + checksum: 10c0/914b1e217cf38436c24b4c60b4c45289e39a45bf9e65ef9fd343c2815a1a02b8a0215aeec8bf9c07c516089004b6e3826332481f40a09529fcadbf6e579f286b + languageName: node + linkType: hard + "get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": version: 2.0.2 resolution: "get-func-name@npm:2.0.2" @@ -9253,6 +9726,15 @@ __metadata: languageName: node linkType: hard +"is-docker@npm:^3.0.0": + version: 3.0.0 + resolution: "is-docker@npm:3.0.0" + bin: + is-docker: cli.js + checksum: 10c0/d2c4f8e6d3e34df75a5defd44991b6068afad4835bb783b902fa12d13ebdb8f41b2a199dcb0b5ed2cb78bfee9e4c0bbdb69c2d9646f4106464674d3e697a5856 + languageName: node + linkType: hard + "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -9294,6 +9776,17 @@ __metadata: languageName: node linkType: hard +"is-inside-container@npm:^1.0.0": + version: 1.0.0 + resolution: "is-inside-container@npm:1.0.0" + dependencies: + is-docker: "npm:^3.0.0" + bin: + is-inside-container: cli.js + checksum: 10c0/a8efb0e84f6197e6ff5c64c52890fa9acb49b7b74fed4da7c95383965da6f0fa592b4dbd5e38a79f87fc108196937acdbcd758fcefc9b140e479b39ce1fcd1cd + languageName: node + linkType: hard + "is-interactive@npm:^1.0.0": version: 1.0.0 resolution: "is-interactive@npm:1.0.0" @@ -9301,6 +9794,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^2.0.0": + version: 2.0.0 + resolution: "is-interactive@npm:2.0.0" + checksum: 10c0/801c8f6064f85199dc6bf99b5dd98db3282e930c3bc197b32f2c5b89313bb578a07d1b8a01365c4348c2927229234f3681eb861b9c2c92bee72ff397390fa600 + languageName: node + linkType: hard + "is-lambda@npm:^1.0.1": version: 1.0.1 resolution: "is-lambda@npm:1.0.1" @@ -9513,6 +10013,20 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^1.3.0": + version: 1.3.0 + resolution: "is-unicode-supported@npm:1.3.0" + checksum: 10c0/b8674ea95d869f6faabddc6a484767207058b91aea0250803cbf1221345cb0c56f466d4ecea375dc77f6633d248d33c47bd296fb8f4cdba0b4edba8917e83d8a + languageName: node + linkType: hard + +"is-unicode-supported@npm:^2.0.0": + version: 2.0.0 + resolution: "is-unicode-supported@npm:2.0.0" + checksum: 10c0/3013dfb8265fe9f9a0d1e9433fc4e766595631a8d85d60876c457b4bedc066768dab1477c553d02e2f626d88a4e019162706e04263c94d74994ef636a33b5f94 + languageName: node + linkType: hard + "is-weakmap@npm:^2.0.2": version: 2.0.2 resolution: "is-weakmap@npm:2.0.2" @@ -9548,6 +10062,15 @@ __metadata: languageName: node linkType: hard +"is-wsl@npm:^3.1.0": + version: 3.1.0 + resolution: "is-wsl@npm:3.1.0" + dependencies: + is-inside-container: "npm:^1.0.0" + checksum: 10c0/d3317c11995690a32c362100225e22ba793678fe8732660c6de511ae71a0ff05b06980cf21f98a6bf40d7be0e9e9506f859abe00a1118287d63e53d0a3d06947 + languageName: node + linkType: hard + "isarray@npm:^2.0.5": version: 2.0.5 resolution: "isarray@npm:2.0.5" @@ -10263,6 +10786,15 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^7.2.0": + version: 7.2.0 + resolution: "locate-path@npm:7.2.0" + dependencies: + p-locate: "npm:^6.0.0" + checksum: 10c0/139e8a7fe11cfbd7f20db03923cacfa5db9e14fa14887ea121345597472b4a63c1a42a8a5187defeeff6acf98fd568da7382aa39682d38f0af27433953a97751 + languageName: node + linkType: hard + "lodash.get@npm:^4.4.2": version: 4.4.2 resolution: "lodash.get@npm:4.4.2" @@ -10308,6 +10840,16 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^6.0.0": + version: 6.0.0 + resolution: "log-symbols@npm:6.0.0" + dependencies: + chalk: "npm:^5.3.0" + is-unicode-supported: "npm:^1.3.0" + checksum: 10c0/36636cacedba8f067d2deb4aad44e91a89d9efb3ead27e1846e7b82c9a10ea2e3a7bd6ce28a7ca616bebc60954ff25c67b0f92d20a6a746bb3cc52c3701891f6 + languageName: node + linkType: hard + "loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -10337,6 +10879,13 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^3.1.2": + version: 3.1.2 + resolution: "loupe@npm:3.1.2" + checksum: 10c0/b13c02e3ddd6a9d5f8bf84133b3242de556512d824dddeea71cce2dbd6579c8f4d672381c4e742d45cf4423d0701765b4a6e5fbc24701def16bc2b40f8daa96a + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.2.2 resolution: "lru-cache@npm:10.2.2" @@ -10403,6 +10952,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.12": + version: 0.30.17 + resolution: "magic-string@npm:0.30.17" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + checksum: 10c0/16826e415d04b88378f200fe022b53e638e3838b9e496edda6c0e086d7753a44a6ed187adc72d19f3623810589bf139af1a315541cd6a26ae0771a0193eaf7b8 + languageName: node + linkType: hard + "magicast@npm:^0.3.2": version: 0.3.2 resolution: "magicast@npm:0.3.2" @@ -10645,6 +11203,13 @@ __metadata: languageName: node linkType: hard +"mimic-function@npm:^5.0.0": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: 10c0/f3d9464dd1816ecf6bdf2aec6ba32c0728022039d992f178237d8e289b48764fee4131319e72eedd4f7f094e22ded0af836c3187a7edc4595d28dd74368fd81d + languageName: node + linkType: hard + "min-indent@npm:^1.0.0": version: 1.0.1 resolution: "min-indent@npm:1.0.1" @@ -10905,7 +11470,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 @@ -11017,6 +11582,13 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "mute-stream@npm:2.0.0" + checksum: 10c0/2cf48a2087175c60c8dcdbc619908b49c07f7adcfc37d29236b0c5c612d6204f789104c98cc44d38acab7b3c96f4a3ec2cfdc4934d0738d876dbefa2a12c69f4 + languageName: node + linkType: hard + "mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" @@ -11770,6 +12342,27 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^7.0.0": + version: 7.0.0 + resolution: "onetime@npm:7.0.0" + dependencies: + mimic-function: "npm:^5.0.0" + checksum: 10c0/5cb9179d74b63f52a196a2e7037ba2b9a893245a5532d3f44360012005c9cadb60851d56716ebff18a6f47129dab7168022445df47c2aff3b276d92585ed1221 + languageName: node + linkType: hard + +"open@npm:^10.1.0": + version: 10.1.0 + resolution: "open@npm:10.1.0" + dependencies: + default-browser: "npm:^5.2.1" + define-lazy-prop: "npm:^3.0.0" + is-inside-container: "npm:^1.0.0" + is-wsl: "npm:^3.1.0" + checksum: 10c0/c86d0b94503d5f735f674158d5c5d339c25ec2927562f00ee74590727292ed23e1b8d9336cb41ffa7e1fa4d3641d29b199b4ea37c78cb557d72b511743e90ebb + languageName: node + linkType: hard + "open@npm:^8.4.0": version: 8.4.2 resolution: "open@npm:8.4.2" @@ -11837,6 +12430,23 @@ __metadata: languageName: node linkType: hard +"ora@npm:^8.1.0": + version: 8.1.0 + resolution: "ora@npm:8.1.0" + dependencies: + chalk: "npm:^5.3.0" + cli-cursor: "npm:^5.0.0" + cli-spinners: "npm:^2.9.2" + is-interactive: "npm:^2.0.0" + is-unicode-supported: "npm:^2.0.0" + log-symbols: "npm:^6.0.0" + stdin-discarder: "npm:^0.2.2" + string-width: "npm:^7.2.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/4ac9a6dd7fe915a354680f33ced21ee96d13d3c5ab0dc00b3c3ba9e3695ed141b1d045222990f5a71a9a91f801042a0b0d32e58dfc5509ff9b81efdd3fcf6339 + languageName: node + linkType: hard + "os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" @@ -11885,6 +12495,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^4.0.0": + version: 4.0.0 + resolution: "p-limit@npm:4.0.0" + dependencies: + yocto-queue: "npm:^1.0.0" + checksum: 10c0/a56af34a77f8df2ff61ddfb29431044557fcbcb7642d5a3233143ebba805fc7306ac1d448de724352861cb99de934bc9ab74f0d16fe6a5460bdbdf938de875ad + languageName: node + linkType: hard + "p-limit@npm:^5.0.0": version: 5.0.0 resolution: "p-limit@npm:5.0.0" @@ -11921,6 +12540,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^6.0.0": + version: 6.0.0 + resolution: "p-locate@npm:6.0.0" + dependencies: + p-limit: "npm:^4.0.0" + checksum: 10c0/d72fa2f41adce59c198270aa4d3c832536c87a1806e0f69dffb7c1a7ca998fb053915ca833d90f166a8c082d3859eabfed95f01698a3214c20df6bb8de046312 + languageName: node + linkType: hard + "p-map-series@npm:2.1.0": version: 2.1.0 resolution: "p-map-series@npm:2.1.0" @@ -12107,6 +12735,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^5.0.0": + version: 5.0.0 + resolution: "path-exists@npm:5.0.0" + checksum: 10c0/b170f3060b31604cde93eefdb7392b89d832dfbc1bed717c9718cbe0f230c1669b7e75f87e19901da2250b84d092989a0f9e44d2ef41deb09aa3ad28e691a40a + languageName: node + linkType: hard + "path-is-absolute@npm:^1.0.0": version: 1.0.1 resolution: "path-is-absolute@npm:1.0.1" @@ -13540,6 +14175,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^5.0.0": + version: 5.1.0 + resolution: "restore-cursor@npm:5.1.0" + dependencies: + onetime: "npm:^7.0.0" + signal-exit: "npm:^4.1.0" + checksum: 10c0/c2ba89131eea791d1b25205bdfdc86699767e2b88dee2a590b1a6caa51737deac8bad0260a5ded2f7c074b7db2f3a626bcf1fcf3cdf35974cbeea5e2e6764f60 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -13799,6 +14444,13 @@ __metadata: languageName: node linkType: hard +"run-applescript@npm:^7.0.0": + version: 7.0.0 + resolution: "run-applescript@npm:7.0.0" + checksum: 10c0/bd821bbf154b8e6c8ecffeaf0c33cebbb78eb2987476c3f6b420d67ab4c5301faa905dec99ded76ebb3a7042b4e440189ae6d85bbbd3fc6e8d493347ecda8bfe + languageName: node + linkType: hard + "run-async@npm:^2.4.0": version: 2.4.1 resolution: "run-async@npm:2.4.1" @@ -14344,6 +14996,20 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.8.0": + version: 3.8.0 + resolution: "std-env@npm:3.8.0" + checksum: 10c0/f560a2902fd0fa3d648d7d0acecbd19d664006f7372c1fba197ed4c216b4c9e48db6e2769b5fe1616d42a9333c9f066c5011935035e85c59f45dc4f796272040 + languageName: node + linkType: hard + +"stdin-discarder@npm:^0.2.2": + version: 0.2.2 + resolution: "stdin-discarder@npm:0.2.2" + checksum: 10c0/c78375e82e956d7a64be6e63c809c7f058f5303efcaf62ea48350af072bacdb99c06cba39209b45a071c1acbd49116af30df1df9abb448df78a6005b72f10537 + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.0.0": version: 1.0.0 resolution: "stop-iteration-iterator@npm:1.0.0" @@ -14396,6 +15062,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^7.2.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + "string.prototype.includes@npm:^2.0.0": version: 2.0.0 resolution: "string.prototype.includes@npm:2.0.0" @@ -14530,7 +15207,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1": +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" dependencies: @@ -14583,6 +15260,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:^5.0.1": + version: 5.0.1 + resolution: "strip-json-comments@npm:5.0.1" + checksum: 10c0/c9d9d55a0167c57aa688df3aa20628cf6f46f0344038f189eaa9d159978e80b2bfa6da541a40d83f7bde8a3554596259bf6b70578b2172356536a0e3fa5a0982 + languageName: node + linkType: hard + "strip-literal@npm:^2.0.0": version: 2.1.0 resolution: "strip-literal@npm:2.1.0" @@ -14877,6 +15561,20 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.1": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + "tinypool@npm:^0.8.3": version: 0.8.4 resolution: "tinypool@npm:0.8.4" @@ -14891,6 +15589,13 @@ __metadata: languageName: node linkType: hard +"tinypool@npm:^1.0.1": + version: 1.0.2 + resolution: "tinypool@npm:1.0.2" + checksum: 10c0/31ac184c0ff1cf9a074741254fe9ea6de95026749eb2b8ec6fd2b9d8ca94abdccda731f8e102e7f32e72ed3b36d32c6975fd5f5523df3f1b6de6c3d8dfd95e63 + languageName: node + linkType: hard + "tinyrainbow@npm:^1.2.0": version: 1.2.0 resolution: "tinyrainbow@npm:1.2.0" @@ -14912,6 +15617,13 @@ __metadata: languageName: node linkType: hard +"tinyspy@npm:^3.0.2": + version: 3.0.2 + resolution: "tinyspy@npm:3.0.2" + checksum: 10c0/55ffad24e346622b59292e097c2ee30a63919d5acb7ceca87fc0d1c223090089890587b426e20054733f97a58f20af2c349fb7cc193697203868ab7ba00bcea0 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -15367,6 +16079,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.5.4": + version: 5.5.4 + resolution: "typescript@npm:5.5.4" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/422be60f89e661eab29ac488c974b6cc0a660fb2228003b297c3d10c32c90f3bcffc1009b43876a082515a3c376b1eefcce823d6e78982e6878408b9a923199c + languageName: node + linkType: hard + "typescript@npm:^5.7.3": version: 5.7.3 resolution: "typescript@npm:5.7.3" @@ -15407,6 +16129,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.5.4#optional!builtin": + version: 5.5.4 + resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=5adc0c" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/10dd9881baba22763de859e8050d6cb6e2db854197495c6f1929b08d1eb2b2b00d0b5d9b0bcee8472f1c3f4a7ef6a5d7ebe0cfd703f853aa5ae465b8404bc1ba + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": version: 5.7.3 resolution: "typescript@patch:typescript@npm%3A5.7.3#optional!builtin::version=5.7.3&hash=5adc0c" @@ -15459,6 +16191,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.19.2": + version: 6.19.8 + resolution: "undici-types@npm:6.19.8" + checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 + languageName: node + linkType: hard + "undici-types@npm:~6.20.0": version: 6.20.0 resolution: "undici-types@npm:6.20.0" @@ -15466,6 +16205,13 @@ __metadata: languageName: node linkType: hard +"unicorn-magic@npm:^0.1.0": + version: 0.1.0 + resolution: "unicorn-magic@npm:0.1.0" + checksum: 10c0/e4ed0de05b0a05e735c7d8a2930881e5efcfc3ec897204d5d33e7e6247f4c31eac92e383a15d9a6bccb7319b4271ee4bea946e211bf14951fec6ff2cbbb66a92 + languageName: node + linkType: hard + "union@npm:~0.5.0": version: 0.5.0 resolution: "union@npm:0.5.0" @@ -15714,6 +16460,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:2.1.8": + version: 2.1.8 + resolution: "vite-node@npm:2.1.8" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.7" + es-module-lexer: "npm:^1.5.4" + pathe: "npm:^1.1.2" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/cb28027a7425ba29780e216164c07d36a4ff9eb60d83afcad3bc222fd5a5f3e36030071c819edd6d910940f502d49e52f7564743617bc1c5875485b0952c72d5 + languageName: node + linkType: hard + "vite-plugin-dts@npm:^3.7.0": version: 3.7.0 resolution: "vite-plugin-dts@npm:3.7.0" @@ -16010,6 +16771,56 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^2.1.8": + version: 2.1.8 + resolution: "vitest@npm:2.1.8" + dependencies: + "@vitest/expect": "npm:2.1.8" + "@vitest/mocker": "npm:2.1.8" + "@vitest/pretty-format": "npm:^2.1.8" + "@vitest/runner": "npm:2.1.8" + "@vitest/snapshot": "npm:2.1.8" + "@vitest/spy": "npm:2.1.8" + "@vitest/utils": "npm:2.1.8" + chai: "npm:^5.1.2" + debug: "npm:^4.3.7" + expect-type: "npm:^1.1.0" + magic-string: "npm:^0.30.12" + pathe: "npm:^1.1.2" + std-env: "npm:^3.8.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.1" + tinypool: "npm:^1.0.1" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + vite-node: "npm:2.1.8" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 2.1.8 + "@vitest/ui": 2.1.8 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/e70631bad5662d6c60c5cf836a4baf58b890db6654fef1f608fe6a86aa49a2b9f078aac74b719d4d3c87c5c781968cc73590a7935277b48f3d8b6fb9c5b4d276 + languageName: node + linkType: hard + "vscode-uri@npm:^3.0.8": version: 3.0.8 resolution: "vscode-uri@npm:3.0.8" @@ -16640,3 +17451,10 @@ __metadata: checksum: 10c0/3242da6b2d8da3bc9a66876ef01a1d5f0d0ad7bd70b0e3e24f5dc6ef5f6213e6e660f14f3dceee9b000692a47b86b365c0ea43b5340153efcb2808ccbfb3fc6f languageName: node linkType: hard + +"zod@npm:^3.24.1": + version: 3.24.1 + resolution: "zod@npm:3.24.1" + checksum: 10c0/0223d21dbaa15d8928fe0da3b54696391d8e3e1e2d0283a1a070b5980a1dbba945ce631c2d1eccc088fdbad0f2dfa40155590bf83732d3ac4fcca2cc9237591b + languageName: node + linkType: hard