From 7f3c22bdc7bf63f1cb814520e9c99bc0f0817031 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 29 Aug 2024 22:03:21 +0100 Subject: [PATCH 01/60] feat: initial cli commit --- packages/cli/.prettierignore | 3 + packages/cli/README.md | 1 + packages/cli/commands/apps.ts | 46 +++ packages/cli/commands/auth.ts | 34 ++ packages/cli/commands/features.ts | 77 +++++ packages/cli/eslint.config.js | 3 + packages/cli/index.ts | 22 ++ packages/cli/package.json | 31 ++ packages/cli/services/apps.ts | 22 ++ packages/cli/services/features.ts | 53 +++ packages/cli/tsconfig.eslint.json | 3 + packages/cli/tsconfig.json | 11 + packages/cli/utils/auth.ts | 103 ++++++ packages/cli/utils/config.ts | 24 ++ packages/cli/utils/constants.ts | 15 + packages/cli/utils/gen.ts | 18 + packages/eslint-config/base.js | 15 +- yarn.lock | 529 +++++++++++++++++++++++++++++- 18 files changed, 988 insertions(+), 22 deletions(-) create mode 100644 packages/cli/.prettierignore create mode 100644 packages/cli/README.md create mode 100644 packages/cli/commands/apps.ts create mode 100644 packages/cli/commands/auth.ts create mode 100644 packages/cli/commands/features.ts create mode 100644 packages/cli/eslint.config.js create mode 100755 packages/cli/index.ts create mode 100644 packages/cli/package.json create mode 100644 packages/cli/services/apps.ts create mode 100644 packages/cli/services/features.ts create mode 100644 packages/cli/tsconfig.eslint.json create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/utils/auth.ts create mode 100644 packages/cli/utils/config.ts create mode 100644 packages/cli/utils/constants.ts create mode 100644 packages/cli/utils/gen.ts diff --git a/packages/cli/.prettierignore b/packages/cli/.prettierignore new file mode 100644 index 00000000..3dc99d75 --- /dev/null +++ b/packages/cli/.prettierignore @@ -0,0 +1,3 @@ +dist +eslint-report.json +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/apps.ts b/packages/cli/commands/apps.ts new file mode 100644 index 00000000..adf36101 --- /dev/null +++ b/packages/cli/commands/apps.ts @@ -0,0 +1,46 @@ +import { select } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; + +import { listApps } from "../services/apps.js"; +import { checkAuth } from "../utils/auth.js"; +import { writeConfig } from "../utils/config.js"; + +export const appCommand = new Command("apps").description("Manage apps"); + +appCommand + .command("list") + .description("List all available apps") + .action(async () => { + checkAuth(); + try { + const apps = await listApps(); + console.log(chalk.green("Available apps:")); + console.table(apps); + } catch (error) { + console.error(chalk.red("Error listing apps:", error)); + } + }); + +appCommand + .command("select") + .description("Select app") + .action(async () => { + checkAuth(); + try { + const apps = await listApps(); + + const answer = await select({ + message: "Select an app", + choices: apps.map((app) => ({ + name: app.name, + value: app.id, + description: app.demo ? "Demo" : undefined, + })), + }); + + await writeConfig("appId", answer); + } catch (error) { + console.error(chalk.red("Error listing apps:", error)); + } + }); diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts new file mode 100644 index 00000000..b44c57ca --- /dev/null +++ b/packages/cli/commands/auth.ts @@ -0,0 +1,34 @@ +import chalk from "chalk"; +import { Command } from "commander"; + +import { + authenticateUser, + removeSessionCookie, + saveSessionCookie, +} from "../utils/auth.js"; + +export const authCommand = new Command("auth").description( + "Manage authentication", +); + +authCommand + .command("login") + .description("Login to Bucket") + .action(async () => { + try { + // Initiate the auth process + const cookies = await authenticateUser(); + await saveSessionCookie(cookies); + console.log(chalk.green("Logged in successfully.")); + } catch (error) { + console.error(chalk.red("Authentication failed."), error); + } + }); + +authCommand + .command("logout") + .description("Logout from Bucket") + .action(() => { + removeSessionCookie(); + console.log(chalk.green("Logged out successfully.")); + }); diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts new file mode 100644 index 00000000..013e4ab0 --- /dev/null +++ b/packages/cli/commands/features.ts @@ -0,0 +1,77 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import { outputFile } from "fs-extra/esm"; + +import { + createFeature, + genFeatureTypes, + listFeatures, + rolloutFeature, +} from "../services/features.js"; +import { checkAuth } from "../utils/auth.js"; +import { GEN_TYPES_FILE } from "../utils/constants.js"; + +export const featuresCommand = new Command("features").description( + "Manage features", +); + +featuresCommand + .command("list") + .description("List all features") + .requiredOption("-a, --appId ", "Get all features in app") + .action(async (options) => { + checkAuth(); + try { + const features = await listFeatures(options.appId); + console.log(chalk.green("Features:")); + console.table(features); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Error fetching features:", error.message)); + } + } + }); + +featuresCommand + .command("create") + .description("Create a new feature") + .action(async () => { + checkAuth(); + try { + await createFeature(); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Error creating feature:", error.message)); + } + } + }); + +featuresCommand + .command("types") + .description("Generate feature types") + .requiredOption("-a, --appId ", "Get all features in app") + .action(async (options) => { + checkAuth(); + try { + const types = await genFeatureTypes(options.appId); + await outputFile(GEN_TYPES_FILE, types); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Error listing feature types:", error.message)); + } + } + }); + +featuresCommand + .command("rollout") + .description("Rollout a feature") + .action(async () => { + checkAuth(); + try { + await rolloutFeature(); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Error rolling out feature:", error.message)); + } + } + }); diff --git a/packages/cli/eslint.config.js b/packages/cli/eslint.config.js new file mode 100644 index 00000000..cc05170e --- /dev/null +++ b/packages/cli/eslint.config.js @@ -0,0 +1,3 @@ +const base = require("@bucketco/eslint-config/base"); + +module.exports = [...base, { ignores: ["dist/", "gen/"] }]; diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100755 index 00000000..ac0471bb --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { program } from "commander"; + +import { appCommand } from "./commands/apps.js"; +import { authCommand } from "./commands/auth.js"; +import { featuresCommand } from "./commands/features.js"; +import { loadSessionCookie } from "./utils/auth.js"; + +async function main() { + // Main program + program + .addCommand(authCommand) + .addCommand(appCommand) + .addCommand(featuresCommand); + + // Load the access token before parsing arguments + await loadSessionCookie(); + + program.parse(process.argv); +} + +main(); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..fe32bd3f --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,31 @@ +{ + "name": "bucket-cli", + "version": "1.0.0", + "packageManager": "yarn@4.1.1", + "description": "CLI for Bucket service", + "main": "./dist/index.js", + "type": "module", + "bin": { + "bucket": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "start": "yarn build && node dist/index.js" + }, + "dependencies": { + "@inquirer/prompts": "^5.3.8", + "axios": "^1.7.5", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "fs-extra": "^11.2.0", + "open": "^10.1.0" + }, + "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" + } +} diff --git a/packages/cli/services/apps.ts b/packages/cli/services/apps.ts new file mode 100644 index 00000000..26e56ded --- /dev/null +++ b/packages/cli/services/apps.ts @@ -0,0 +1,22 @@ +import { AxiosError } from "axios"; +import chalk from "chalk"; + +import { authRequest } from "../utils/auth.js"; + +type App = { + id: string; + name: string; + demo: boolean; +}; + +export async function listApps(): Promise { + try { + const response = await authRequest(`/bootstrap`); + return response.org.apps; + } catch (error) { + if (error instanceof AxiosError && error.response) { + console.error(chalk.red("Authentication failed."), error.response.data); + } + return []; + } +} diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts new file mode 100644 index 00000000..45cec604 --- /dev/null +++ b/packages/cli/services/features.ts @@ -0,0 +1,53 @@ +import { AxiosError } from "axios"; +import chalk from "chalk"; + +import { authRequest } from "../utils/auth.js"; +import { genDTS } from "../utils/gen.js"; + +type Feature = { + name: string; + createdAt: string; +}; + +export async function listFeatures(appId: string): Promise { + try { + const response = await authRequest( + `/apps/${appId}/features?envId=enPa3R6khIKcWA`, + ); + return response.data.map(({ name, createdAt }: Feature) => ({ + name, + createdAt, + })); + } catch (error) { + if (error instanceof AxiosError && error.response) { + console.dir(error.response.data, { depth: null }); + } + throw error; + } +} + +export async function createFeature() { + // Implement feature creation logic here + console.log("Feature creation not implemented yet."); +} + +export async function genFeatureTypes(appId: string) { + try { + const response = await authRequest( + `/apps/${appId}/features?envId=enPa3R6khIKcWA`, + ); + return genDTS( + response.data.map(({ flagKey }: { flagKey: string }) => flagKey), + ) as string; + } catch (error) { + if (error instanceof AxiosError && error.response) { + console.error(chalk.red("Authentication failed."), error.response.data); + } + throw error; + } +} + +export async function rolloutFeature() { + // Implement feature rollout logic here + console.log("Feature rollout not implemented yet."); +} 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..a25cfde5 --- /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/auth.ts b/packages/cli/utils/auth.ts new file mode 100644 index 00000000..2ea5c9d1 --- /dev/null +++ b/packages/cli/utils/auth.ts @@ -0,0 +1,103 @@ +import http from "http"; +import axios, { AxiosError, AxiosRequestConfig } from "axios"; +import chalk from "chalk"; +import open from "open"; + +import { readConfig, writeConfig } from "./config.js"; +import { API_BASE_URL } from "./constants.js"; + +/** + * @return {Promise} + */ +export async function authenticateUser() { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + + if (url.pathname !== "/") { + res.writeHead(404).end("Not Found"); + server.close(); + reject(new Error("Authentication failed")); + return; + } + + if (!req.headers.cookie?.includes("session.sig")) { + res.writeHead(400).end("No session cookie found"); + server.close(); + reject(new Error("Authentication failed: No session cookie")); + return; + } + + res.end("OK"); + server.close(); + resolve(req.headers.cookie); + }); + + server.listen(); + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + const redirect = `http://localhost:${port}`; + open(`${API_BASE_URL}/auth/google?redirect=${redirect}`, { + newInstance: true, + }); + } + }); +} + +let sessionCookies = ""; + +export function checkAuth() { + if (!sessionCookies) { + console.log( + chalk.red( + 'You are not authenticated. Please run "bucket auth login" first.', + ), + ); + process.exit(1); + } +} + +export async function saveSessionCookie(cookies: string) { + await writeConfig("sessionCookies", cookies); + sessionCookies = cookies; +} + +export async function loadSessionCookie() { + sessionCookies = await readConfig("sessionCookies"); +} + +export function removeSessionCookie() { + saveSessionCookie(""); + sessionCookies = ""; +} + +export function getSessionCookie() { + return sessionCookies; +} + +export async function authRequest(url: string, options?: AxiosRequestConfig) { + checkAuth(); + try { + const response = await axios({ + ...options, + url: `${API_BASE_URL}${url}`, + headers: { + ...options?.headers, + Cookie: sessionCookies, + }, + }); + return response.data; + } catch (error) { + if ( + error instanceof AxiosError && + error.response && + error.response.status === 401 + ) { + console.log(chalk.red("Your session has expired. Please login again.")); + removeSessionCookie(); + process.exit(1); + } + throw error; + } +} diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts new file mode 100644 index 00000000..1e6cf21c --- /dev/null +++ b/packages/cli/utils/config.ts @@ -0,0 +1,24 @@ +import { readJson, writeJson } from "fs-extra/esm"; + +import { CONFIG_FILE } from "./constants.js"; + +/** + * Read a value from the config file. + */ +export async function readConfig(key?: string) { + try { + const config = await readJson(CONFIG_FILE); + return key ? config[key] : config; + } catch (error) { + return {}; + } +} + +/** + * Write a new value to the config file. + */ +export async function writeConfig(key: string, value: string) { + const config = await readConfig(); + config[key] = value; + await writeJson(CONFIG_FILE, config); +} diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts new file mode 100644 index 00000000..3cc0cfa3 --- /dev/null +++ b/packages/cli/utils/constants.ts @@ -0,0 +1,15 @@ +import path from "path"; + +export const API_BASE_URL = + "http://localhost:3100/api" ?? "https://app.bucket.co/api"; + +export const CONFIG_FILE = path.join( + process.env.HOME || "", + ".bucket-cli-config.json", +); + +export const GEN_TYPES_FILE = path.join( + process.cwd(), + "gen", + "feature-flag-types.ts", +); diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts new file mode 100644 index 00000000..20de7302 --- /dev/null +++ b/packages/cli/utils/gen.ts @@ -0,0 +1,18 @@ +export const genDTS = (keys: string[]) => { + return /* ts */ ` +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET-CLI AND WILL BE OVERWRITTEN. +const availableFeatures = [ + "${keys.join('",\n "')}" +] as const; + +type ArrayToRecord = { + [Key in T[number]]: boolean; +}; + +export type AvailableFeatures = ArrayToRecord; + +declare module "@bucketco/react-sdk" { + interface Features extends AvailableFeatures {} +} +`.trim(); +}; diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 2306b805..72fa2a92 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -10,6 +10,11 @@ const prettierConfig = require("eslint-config-prettier"); module.exports = [ { + // Blacklisted Folders, including **/node_modules/ and .git/ + ignores: ["build/", "**/gen"], + }, + { + // All files files: [ "**/*.js", "**/*.cjs", @@ -33,7 +38,6 @@ module.exports = [ // This is required to avoid ecmaVersion < 2015 error or 'import' / 'export' error ecmaVersion: "latest", sourceType: "module", - project: "./tsconfig.json", }, }, settings: { @@ -96,7 +100,7 @@ module.exports = [ languageOptions: { parser: tsParser, parserOptions: { - project: "./tsconfig.eslint.json", + project: "./tsconfig.json", }, }, settings: { @@ -117,13 +121,12 @@ module.exports = [ // Typescript Specific "@typescript-eslint/no-unused-vars": "off", // handled by unused-imports "@typescript-eslint/explicit-module-boundary-types": ["off"], - "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/switch-exhaustiveness-check": ["warn"], "@typescript-eslint/no-non-null-assertion": ["off"], - "@typescript-eslint/no-empty-function": ["warn"], + "@typescript-eslint/no-empty-function": ["off"], "@typescript-eslint/no-explicit-any": ["off"], - "@typescript-eslint/no-use-before-define": ["off"], - "@typescript-eslint/no-shadow": ["warn"], + "@typescript-eslint/no-use-before-define": ["off"], // todo: discuss enabling this rule + "@typescript-eslint/no-shadow": ["off"], // todo: discuss enabling this rule }, }, { diff --git a/yarn.lock b/yarn.lock index 9aa63b6d..24972d3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29,6 +29,16 @@ __metadata: languageName: node linkType: hard +"@ampproject/remapping@npm:^2.3.0": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed + languageName: node + linkType: hard + "@asamuzakjp/dom-selector@npm:^2.0.1": version: 2.0.2 resolution: "@asamuzakjp/dom-selector@npm:2.0.2" @@ -948,16 +958,7 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/node-sdk@npm:>=1.4.2": - version: 1.5.0 - resolution: "@bucketco/node-sdk@npm:1.5.0" - dependencies: - "@bucketco/flag-evaluation": "npm:~0.1.0" - checksum: 10c0/63230400c0c0fa6ccf8708550bbcf583cc58bd18a2b99e19ec1dde43bce593c43136790ff3f0573f171c123c6a0555eebafcefdfa5cc71a2e706079fdb1ebe39 - languageName: node - linkType: hard - -"@bucketco/node-sdk@workspace:packages/node-sdk": +"@bucketco/node-sdk@npm:>=1.4.2, @bucketco/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@bucketco/node-sdk@workspace:packages/node-sdk" dependencies: @@ -1884,6 +1885,19 @@ __metadata: languageName: node linkType: hard +"@inquirer/checkbox@npm:^2.4.7": + version: 2.4.7 + resolution: "@inquirer/checkbox@npm:2.4.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/92bd2727f316e2304b150ef32eb3023200512de154b49b6e121f468c2ad6fa58cb3b37d9dc91d1a2dc039e932dd934df5ce481acb2555cacac2b0308acb05576 + languageName: node + linkType: hard + "@inquirer/confirm@npm:^3.0.0": version: 3.1.17 resolution: "@inquirer/confirm@npm:3.1.17" @@ -1894,6 +1908,37 @@ __metadata: languageName: node linkType: hard +"@inquirer/confirm@npm:^3.1.22": + version: 3.1.22 + resolution: "@inquirer/confirm@npm:3.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 10c0/99e1a17e62a674d8e440a11bf4e9d5a62666247823b091314e52ee40929a6a6e8ce60086ec653bbeb59117bfc940d807c6f4b604cf5cf51f24009b9d09d5bf98 + languageName: node + linkType: hard + +"@inquirer/core@npm:^9.0.10": + version: 9.0.10 + resolution: "@inquirer/core@npm:9.0.10" + dependencies: + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + "@types/mute-stream": "npm:^0.0.4" + "@types/node": "npm:^22.1.0" + "@types/wrap-ansi": "npm:^3.0.0" + ansi-escapes: "npm:^4.3.2" + cli-spinners: "npm:^2.9.2" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^1.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/117f50a55b5ebee8bfc62ea6adec87035f28ee7ace1efea67895c3d32ab50bf569ecd3ca33c457d0c7ae4240b9fe4d7b698ab70946ac561ab579d0920ddc98bb + languageName: node + linkType: hard + "@inquirer/core@npm:^9.0.5": version: 9.0.5 resolution: "@inquirer/core@npm:9.0.5" @@ -1915,6 +1960,28 @@ __metadata: languageName: node linkType: hard +"@inquirer/editor@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/editor@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + external-editor: "npm:^3.1.0" + checksum: 10c0/a2e65362ed187695450b092c8f5698661002e4e24e1e800dfbbfeaa8e71f60b5d5e1dfa98b9402457c02cab1762ac2b20d3364c11db0b5572aa61caf55f5beba + languageName: node + linkType: hard + +"@inquirer/expand@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/expand@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/0f9d3447ca6b9e24e0179b4ec53f463647a8323d8a041bc3321f19a2914176117a264bcc6deb723e3f9ec718d3faf672f3f840f0898bbff4371fa899b239b411 + languageName: node + linkType: hard + "@inquirer/figures@npm:^1.0.5": version: 1.0.5 resolution: "@inquirer/figures@npm:1.0.5" @@ -1922,6 +1989,91 @@ __metadata: languageName: node linkType: hard +"@inquirer/input@npm:^2.2.9": + version: 2.2.9 + resolution: "@inquirer/input@npm:2.2.9" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 10c0/0fcdc5d9c17712f9a2c79f39d1e03ed4a58cfe1dd1095209b4c83621dba2cb94db03b7df0df34e22584bce9e53403a284c76721def66a452e4751666d945d99f + languageName: node + linkType: hard + +"@inquirer/number@npm:^1.0.10": + version: 1.0.10 + resolution: "@inquirer/number@npm:1.0.10" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 10c0/efa7c49322d8f36eeb8afb704bba673c10fcf7992babc8ad5f25d4c8db0fbafc0007439abdef05a462171b37a68b981981859587ff5b71e79002ffac65be859a + languageName: node + linkType: hard + +"@inquirer/password@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/password@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + checksum: 10c0/5cd5bab0026d673539710f424e6f7dda8abea4863a0cddf982278b15a250f5a2be0a0f17fdf970a900d33187a64ad987d24d038cfbed2599b5a2a97c169bbddc + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^5.3.8": + version: 5.3.8 + resolution: "@inquirer/prompts@npm:5.3.8" + dependencies: + "@inquirer/checkbox": "npm:^2.4.7" + "@inquirer/confirm": "npm:^3.1.22" + "@inquirer/editor": "npm:^2.1.22" + "@inquirer/expand": "npm:^2.1.22" + "@inquirer/input": "npm:^2.2.9" + "@inquirer/number": "npm:^1.0.10" + "@inquirer/password": "npm:^2.1.22" + "@inquirer/rawlist": "npm:^2.2.4" + "@inquirer/search": "npm:^1.0.7" + "@inquirer/select": "npm:^2.4.7" + checksum: 10c0/2c49afb5e9f7d825c1489d8c587f985418e890508802e1483d1cb8da46644e9803b2f0a8b71f53f0ff5d9273ca39e28faeadf7d6691467eb5c0dbbde900e5233 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^2.2.4": + version: 2.2.4 + resolution: "@inquirer/rawlist@npm:2.2.4" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/d7c5e0784bb357f6465b7ca08a22310f124fb61db6cce7a1860305d8d25dcdfa66db216d4cc52873d68ae379376984cf8d9cd14880fbca41b7b03802be49caf8 + languageName: node + linkType: hard + +"@inquirer/search@npm:^1.0.7": + version: 1.0.7 + resolution: "@inquirer/search@npm:1.0.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/29179cc32236689b956cccdc092b537c67e841c5cd0a6473b92f9e71f56c0fb737baa4856bf76572f07c0c3999b6b5ea1ce3b74ef56504e179098f700a85a5cf + languageName: node + linkType: hard + +"@inquirer/select@npm:^2.4.7": + version: 2.4.7 + resolution: "@inquirer/select@npm:2.4.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/34e120a263ca2e7edeab08ef6ca24d0966135e8d1a9d6f167fbc03fa8f057391228d58292281609a25d51eb9d59d0b1d00146bd2a5811c5d3880800321cfe8e6 + languageName: node + linkType: hard + "@inquirer/type@npm:^1.5.1": version: 1.5.1 resolution: "@inquirer/type@npm:1.5.1" @@ -1931,6 +2083,15 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^1.5.2": + version: 1.5.2 + resolution: "@inquirer/type@npm:1.5.2" + dependencies: + mute-stream: "npm:^1.0.0" + checksum: 10c0/e2c91562c07440620bd805a60438b78c188d2727d86f396a68c480e4357469a72cd80bd2c158faa6b987671911566bd4fc12976f4bdda1a3441594e318c40058 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -3756,6 +3917,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" @@ -3804,6 +3975,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" @@ -3843,6 +4023,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.1.0, @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/node@npm:^22.12.0": version: 22.12.0 resolution: "@types/node@npm:22.12.0" @@ -4180,6 +4369,18 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/expect@npm:2.0.5" + dependencies: + "@vitest/spy": "npm:2.0.5" + "@vitest/utils": "npm:2.0.5" + chai: "npm:^5.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/08cb1b0f106d16a5b60db733e3d436fa5eefc68571488eb570dfe4f599f214ab52e4342273b03dbe12331cc6c0cdc325ac6c94f651ad254cd62f3aa0e3d185aa + languageName: node + linkType: hard + "@vitest/expect@npm:2.1.9": version: 2.1.9 resolution: "@vitest/expect@npm:2.1.9" @@ -4211,6 +4412,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.5": + version: 2.0.5 + resolution: "@vitest/pretty-format@npm:2.0.5" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/236c0798c5170a0b5ad5d4bd06118533738e820b4dd30079d8fbcb15baee949d41c60f42a9f769906c4a5ce366d7ef11279546070646c0efc03128c220c31f37 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:2.1.9, @vitest/pretty-format@npm:^2.1.9": version: 2.1.9 resolution: "@vitest/pretty-format@npm:2.1.9" @@ -4231,6 +4441,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/runner@npm:2.0.5" + dependencies: + "@vitest/utils": "npm:2.0.5" + pathe: "npm:^1.1.2" + checksum: 10c0/d0ed3302a7e015bf44b7c0df9d8f7da163659e082d86f9406944b5a31a61ab9ddc1de530e06176d1f4ef0bde994b44bff4c7dab62aacdc235c8fc04b98e4a72a + languageName: node + linkType: hard + "@vitest/runner@npm:2.1.9": version: 2.1.9 resolution: "@vitest/runner@npm:2.1.9" @@ -4252,6 +4472,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/snapshot@npm:2.0.5" + dependencies: + "@vitest/pretty-format": "npm:2.0.5" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + checksum: 10c0/7bf38474248f5ae0aac6afad511785d2b7a023ac5158803c2868fd172b5b9c1a569fb1dd64a09a49e43fd342cab71ea485ada89b7f08d37b1622a5a0ac00271d + languageName: node + linkType: hard + "@vitest/snapshot@npm:2.1.9": version: 2.1.9 resolution: "@vitest/snapshot@npm:2.1.9" @@ -4272,6 +4503,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/spy@npm:2.0.5" + dependencies: + tinyspy: "npm:^3.0.0" + checksum: 10c0/70634c21921eb271b54d2986c21d7ab6896a31c0f4f1d266940c9bafb8ac36237846d6736638cbf18b958bd98e5261b158a6944352742accfde50b7818ff655e + languageName: node + linkType: hard + "@vitest/spy@npm:2.1.9": version: 2.1.9 resolution: "@vitest/spy@npm:2.1.9" @@ -4293,6 +4533,18 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/utils@npm:2.0.5" + dependencies: + "@vitest/pretty-format": "npm:2.0.5" + estree-walker: "npm:^3.0.3" + loupe: "npm:^3.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/0d1de748298f07a50281e1ba058b05dcd58da3280c14e6f016265e950bd79adab6b97822de8f0ea82d3070f585654801a9b1bcf26db4372e51cf7746bf86d73b + languageName: node + linkType: hard + "@vitest/utils@npm:2.1.9": version: 2.1.9 resolution: "@vitest/utils@npm:2.1.9" @@ -5368,6 +5620,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" @@ -5506,6 +5769,27 @@ __metadata: languageName: node linkType: hard +"bucket-cli@workspace:packages/cli": + version: 0.0.0-use.local + resolution: "bucket-cli@workspace:packages/cli" + dependencies: + "@bucketco/eslint-config": "workspace:^" + "@bucketco/tsconfig": "workspace:^" + "@inquirer/prompts": "npm:^5.3.8" + "@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" + fs-extra: "npm:^11.2.0" + open: "npm:^10.1.0" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.5.4" + bin: + bucket: ./dist/index.js + languageName: unknown + linkType: soft + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -5539,6 +5823,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" @@ -5758,6 +6051,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.1.1": + version: 5.1.1 + resolution: "chai@npm:5.1.1" + 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/e7f00e5881e3d5224f08fe63966ed6566bd9fdde175863c7c16dd5240416de9b34c4a0dd925f4fd64ad56256ca6507d32cf6131c49e1db65c62578eb31d4566c + languageName: node + linkType: hard + "chai@npm:^5.1.2": version: 5.1.2 resolution: "chai@npm:5.1.2" @@ -5802,6 +6108,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" @@ -6044,6 +6357,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" @@ -6617,6 +6937,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" @@ -6655,6 +6992,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" @@ -7772,7 +8116,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: @@ -8005,7 +8349,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: @@ -9228,6 +9572,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" @@ -9269,6 +9622,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" @@ -9523,6 +9887,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" @@ -10303,7 +10676,7 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.1.0": +"loupe@npm:^3.1.0, loupe@npm:^3.1.1": version: 3.1.1 resolution: "loupe@npm:3.1.1" dependencies: @@ -11761,6 +12134,18 @@ __metadata: 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" @@ -13790,6 +14175,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" @@ -14328,7 +14720,7 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.5.0": +"std-env@npm:^3.5.0, std-env@npm:^3.7.0": version: 3.7.0 resolution: "std-env@npm:3.7.0" checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e @@ -14868,7 +15260,7 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.5.1": +"tinybench@npm:^2.5.1, tinybench@npm:^2.8.0": version: 2.8.0 resolution: "tinybench@npm:2.8.0" checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d @@ -14896,6 +15288,13 @@ __metadata: languageName: node linkType: hard +"tinypool@npm:^1.0.0": + version: 1.0.0 + resolution: "tinypool@npm:1.0.0" + checksum: 10c0/71b20b9c54366393831c286a0772380c20f8cad9546d724c484edb47aea3228f274c58e98cf51d28c40869b39f5273209ef3ea94a9d2a23f8b292f4731cd3e4e + languageName: node + linkType: hard + "tinypool@npm:^1.0.1": version: 1.0.2 resolution: "tinypool@npm:1.0.2" @@ -14917,6 +15316,13 @@ __metadata: languageName: node linkType: hard +"tinyspy@npm:^3.0.0": + version: 3.0.0 + resolution: "tinyspy@npm:3.0.0" + checksum: 10c0/eb0dec264aa5370efd3d29743825eb115ed7f1ef8a72a431e9a75d5c9e7d67e99d04b0d61d86b8cd70c79ec27863f241ad0317bc453f78762e0cbd76d2c332d0 + languageName: node + linkType: hard + "tinyspy@npm:^3.0.2": version: 3.0.2 resolution: "tinyspy@npm:3.0.2" @@ -15379,6 +15785,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" @@ -15419,6 +15835,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" @@ -15471,6 +15897,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" @@ -15696,6 +16129,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:2.0.5": + version: 2.0.5 + resolution: "vite-node@npm:2.0.5" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.5" + pathe: "npm:^1.1.2" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/affcc58ae8d45bce3e8bc3b5767acd57c24441634e2cd967cf97f4e5ed2bcead1714b60150cdf7ee153ebad47659c5cd419883207e1a95b69790331e3243749f + languageName: node + linkType: hard + "vite-node@npm:2.1.9": version: 2.1.9 resolution: "vite-node@npm:2.1.9" @@ -15909,7 +16357,7 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^2.0.4, vitest@npm:^2.0.5": +"vitest@npm:^2.0.4": version: 2.1.9 resolution: "vitest@npm:2.1.9" dependencies: @@ -15959,6 +16407,55 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^2.0.5": + version: 2.0.5 + resolution: "vitest@npm:2.0.5" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@vitest/expect": "npm:2.0.5" + "@vitest/pretty-format": "npm:^2.0.5" + "@vitest/runner": "npm:2.0.5" + "@vitest/snapshot": "npm:2.0.5" + "@vitest/spy": "npm:2.0.5" + "@vitest/utils": "npm:2.0.5" + chai: "npm:^5.1.1" + debug: "npm:^4.3.5" + execa: "npm:^8.0.1" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + std-env: "npm:^3.7.0" + tinybench: "npm:^2.8.0" + tinypool: "npm:^1.0.0" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + vite-node: "npm:2.0.5" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 2.0.5 + "@vitest/ui": 2.0.5 + 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/b4e6cca00816bf967a8589111ded72faa12f92f94ccdd0dcd0698ffcfdfc52ec662753f66b387549c600ac699b993fd952efbd99dc57fcf4d1c69a2f1022b259 + languageName: node + linkType: hard + "vscode-uri@npm:^3.0.8": version: 3.0.8 resolution: "vscode-uri@npm:3.0.8" From 1eabe268f8b82c9086f3938d64dd5d3e27a0c814 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Sat, 31 Aug 2024 11:29:19 +0100 Subject: [PATCH 02/60] feat: implemented create feature --- packages/cli/commands/apps.ts | 77 ++++++++-------- packages/cli/commands/auth.ts | 56 ++++++------ packages/cli/commands/envs.ts | 61 +++++++++++++ packages/cli/commands/features.ts | 137 ++++++++++++++++------------- packages/cli/index.ts | 23 ++--- packages/cli/package.json | 1 + packages/cli/services/apps.ts | 22 ----- packages/cli/services/bootstrap.ts | 31 +++++++ packages/cli/services/features.ts | 82 ++++++++--------- packages/cli/utils/auth.ts | 56 ++++-------- packages/cli/utils/config.ts | 34 +++++-- packages/cli/utils/error.ts | 31 +++++++ packages/cli/utils/gen.ts | 23 ++++- yarn.lock | 45 ++++++++++ 14 files changed, 439 insertions(+), 240 deletions(-) create mode 100644 packages/cli/commands/envs.ts delete mode 100644 packages/cli/services/apps.ts create mode 100644 packages/cli/services/bootstrap.ts create mode 100644 packages/cli/utils/error.ts diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index adf36101..d7f70ad0 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -2,45 +2,50 @@ import { select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command } from "commander"; -import { listApps } from "../services/apps.js"; +import { listApps } from "../services/bootstrap.js"; import { checkAuth } from "../utils/auth.js"; -import { writeConfig } from "../utils/config.js"; +import { writeConfigFile } from "../utils/config.js"; +import { handleError } from "../utils/error.js"; -export const appCommand = new Command("apps").description("Manage apps"); +export function registerAppsCommands(program: Command) { + const appsCommand = new Command("apps").description("Manage apps"); -appCommand - .command("list") - .description("List all available apps") - .action(async () => { - checkAuth(); - try { - const apps = await listApps(); - console.log(chalk.green("Available apps:")); - console.table(apps); - } catch (error) { - console.error(chalk.red("Error listing apps:", error)); - } - }); + appsCommand + .command("list") + .description("List all available apps") + .action(async () => { + checkAuth(); + try { + const apps = await listApps(); + console.log(chalk.green("Available apps:")); + console.table(apps); + } catch (error) { + handleError(error, "Failed to list apps:"); + } + }); -appCommand - .command("select") - .description("Select app") - .action(async () => { - checkAuth(); - try { - const apps = await listApps(); + appsCommand + .command("select") + .description("Select app") + .action(async () => { + checkAuth(); + try { + const apps = await listApps(); - const answer = await select({ - message: "Select an app", - choices: apps.map((app) => ({ - name: app.name, - value: app.id, - description: app.demo ? "Demo" : undefined, - })), - }); + const answer = await select({ + message: "Select an app", + choices: apps.map((app) => ({ + name: app.name, + value: app.id, + description: app.demo ? "Demo" : undefined, + })), + }); - await writeConfig("appId", answer); - } catch (error) { - console.error(chalk.red("Error listing apps:", error)); - } - }); + await writeConfigFile("appId", answer); + } catch (error) { + handleError(error, "Failed to select app:"); + } + }); + + program.addCommand(appsCommand); +} diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index b44c57ca..95e585bf 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -1,34 +1,34 @@ import chalk from "chalk"; import { Command } from "commander"; -import { - authenticateUser, - removeSessionCookie, - saveSessionCookie, -} from "../utils/auth.js"; +import { authenticateUser } from "../utils/auth.js"; +import { writeConfigFile } from "../utils/config.js"; +import { handleError } from "../utils/error.js"; -export const authCommand = new Command("auth").description( - "Manage authentication", -); +export function registerAuthCommands(program: Command) { + const authCommand = new Command("auth").description("Manage authentication"); -authCommand - .command("login") - .description("Login to Bucket") - .action(async () => { - try { - // Initiate the auth process - const cookies = await authenticateUser(); - await saveSessionCookie(cookies); - console.log(chalk.green("Logged in successfully.")); - } catch (error) { - console.error(chalk.red("Authentication failed."), error); - } - }); + authCommand + .command("login") + .description("Login to Bucket") + .requiredOption("-a, --appId ", "Get all features in app") + .action(async () => { + try { + // Initiate the auth process + await authenticateUser(); + console.log(chalk.green("Logged in successfully!")); + } catch (error) { + handleError(error, "Authentication failed:"); + } + }); -authCommand - .command("logout") - .description("Logout from Bucket") - .action(() => { - removeSessionCookie(); - console.log(chalk.green("Logged out successfully.")); - }); + authCommand + .command("logout") + .description("Logout from Bucket") + .action(async () => { + await writeConfigFile("sessionCookies", undefined); + console.log(chalk.green("Logged out successfully!")); + }); + + program.addCommand(authCommand); +} diff --git a/packages/cli/commands/envs.ts b/packages/cli/commands/envs.ts new file mode 100644 index 00000000..6d428cfb --- /dev/null +++ b/packages/cli/commands/envs.ts @@ -0,0 +1,61 @@ +import { select } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; + +import { listEnvs } from "../services/bootstrap.js"; +import { checkAuth } from "../utils/auth.js"; +import { getConfig, writeConfigFile } from "../utils/config.js"; +import { CONFIG_FILE } from "../utils/constants.js"; +import { handleError } from "../utils/error.js"; + +export function registerEnvsCommands(program: Command) { + const envsCommand = new Command("envs").description("Manage envs"); + + envsCommand + .command("list") + .description("List all available environments for the app") + .requiredOption( + "-a, --appId ", + `Get all environments for the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .action(async ({ appId }) => { + checkAuth(); + try { + const envs = await listEnvs(appId); + console.log(chalk.green(`Available environments for app ${appId}:`)); + console.table(envs); + } catch (error) { + handleError(error, "Failed to list environment:"); + } + }); + + envsCommand + .command("select") + .description("Select environment") + .requiredOption( + "-a, --appId ", + `Get all environments for the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .action(async ({ appId }) => { + checkAuth(); + try { + const envs = await listEnvs(appId); + + const answer = await select({ + message: "Select an environment", + choices: envs.map(({ id, name }) => ({ + name, + value: id, + })), + }); + + await writeConfigFile("envId", answer); + } catch (error) { + handleError(error, "Failed to select environment:"); + } + }); + + program.addCommand(envsCommand); +} diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 013e4ab0..73123a18 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -6,72 +6,91 @@ import { createFeature, genFeatureTypes, listFeatures, - rolloutFeature, } from "../services/features.js"; import { checkAuth } from "../utils/auth.js"; -import { GEN_TYPES_FILE } from "../utils/constants.js"; +import { getConfig } from "../utils/config.js"; +import { CONFIG_FILE, GEN_TYPES_FILE } from "../utils/constants.js"; +import { handleError } from "../utils/error.js"; -export const featuresCommand = new Command("features").description( - "Manage features", -); +export function registerFeaturesCommands(program: Command) { + const featuresCommand = new Command("features").description( + "Manage features", + ); -featuresCommand - .command("list") - .description("List all features") - .requiredOption("-a, --appId ", "Get all features in app") - .action(async (options) => { - checkAuth(); - try { - const features = await listFeatures(options.appId); - console.log(chalk.green("Features:")); - console.table(features); - } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Error fetching features:", error.message)); + featuresCommand + .command("list") + .description("List all features") + .requiredOption( + "-a, --appId ", + `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .action(async ({ appId }) => { + checkAuth(); + try { + const features = await listFeatures(appId); + console.log(chalk.green(`Features in ${appId}:`)); + console.table(features); + } catch (error) { + handleError(error, "Failed to list features:"); } - } - }); + }); -featuresCommand - .command("create") - .description("Create a new feature") - .action(async () => { - checkAuth(); - try { - await createFeature(); - } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Error creating feature:", error.message)); + featuresCommand + .command("types") + .description("Generate feature types") + .requiredOption( + "-a, --appId ", + `Generate types for features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .option( + "-o, --out ", + `Generate types for features at the output path. Falls back to ${GEN_TYPES_FILE}.`, + GEN_TYPES_FILE, + ) + .action(async ({ appId, out }) => { + checkAuth(); + try { + const types = await genFeatureTypes(appId); + await outputFile(out, types); + console.log(chalk.green(`Generated features for ${appId}.`)); + } catch (error) { + handleError(error, "Failed to generate feature types:"); } - } - }); + }); -featuresCommand - .command("types") - .description("Generate feature types") - .requiredOption("-a, --appId ", "Get all features in app") - .action(async (options) => { - checkAuth(); - try { - const types = await genFeatureTypes(options.appId); - await outputFile(GEN_TYPES_FILE, types); - } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Error listing feature types:", error.message)); + featuresCommand + .command("create") + .description("Create a new feature") + .argument("", "Name of the feature") + .requiredOption( + "-a, --appId ", + `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .requiredOption( + "-e, --envId ", + `Get all features in the env. Falls back to envId stored in ${CONFIG_FILE}.`, + getConfig("envId"), + ) + .option( + "-k, --key ", + `Create a feature in the app with the given feature key. Falls back to a slug of the feature name.`, + ) + .action(async (name, { appId, envId, key }) => { + checkAuth(); + try { + const feature = await createFeature(appId, envId, name, key); + console.log( + chalk.green( + `Created feature ${feature.name} with key ${feature.key}.`, + ), + ); + } catch (error) { + handleError(error, "Failed to create feature:"); } - } - }); + }); -featuresCommand - .command("rollout") - .description("Rollout a feature") - .action(async () => { - checkAuth(); - try { - await rolloutFeature(); - } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Error rolling out feature:", error.message)); - } - } - }); + program.addCommand(featuresCommand); +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts index ac0471bb..c5856819 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,20 +1,21 @@ #!/usr/bin/env node import { program } from "commander"; -import { appCommand } from "./commands/apps.js"; -import { authCommand } from "./commands/auth.js"; -import { featuresCommand } from "./commands/features.js"; -import { loadSessionCookie } from "./utils/auth.js"; +import { registerAppsCommands } from "./commands/apps.js"; +import { registerAuthCommands } from "./commands/auth.js"; +import { registerEnvsCommands } from "./commands/envs.js"; +import { registerFeaturesCommands } from "./commands/features.js"; +import { readConfigFile } from "./utils/config.js"; async function main() { - // Main program - program - .addCommand(authCommand) - .addCommand(appCommand) - .addCommand(featuresCommand); + // Read the config file + await readConfigFile(); - // Load the access token before parsing arguments - await loadSessionCookie(); + // Main program + registerAuthCommands(program); + registerAppsCommands(program); + registerEnvsCommands(program); + registerFeaturesCommands(program); program.parse(process.argv); } diff --git a/packages/cli/package.json b/packages/cli/package.json index fe32bd3f..7a26b9ae 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", + "@sindresorhus/slugify": "^2.2.1", "axios": "^1.7.5", "chalk": "^5.3.0", "commander": "^12.1.0", diff --git a/packages/cli/services/apps.ts b/packages/cli/services/apps.ts deleted file mode 100644 index 26e56ded..00000000 --- a/packages/cli/services/apps.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AxiosError } from "axios"; -import chalk from "chalk"; - -import { authRequest } from "../utils/auth.js"; - -type App = { - id: string; - name: string; - demo: boolean; -}; - -export async function listApps(): Promise { - try { - const response = await authRequest(`/bootstrap`); - return response.org.apps; - } catch (error) { - if (error instanceof AxiosError && error.response) { - console.error(chalk.red("Authentication failed."), error.response.data); - } - return []; - } -} diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts new file mode 100644 index 00000000..0b398517 --- /dev/null +++ b/packages/cli/services/bootstrap.ts @@ -0,0 +1,31 @@ +import { authRequest } from "../utils/auth.js"; + +type Environment = { + id: string; + name: string; + isProduction: boolean; + order: number; +}; + +type App = { + id: string; + name: string; + demo: boolean; + environments: Environment[]; +}; + +type BootstrapResponse = { + org: { + apps: App[]; + }; +}; + +export async function listApps() { + const response = await authRequest(`/bootstrap`); + return response.org.apps.map(({ id, name, demo }) => ({ id, name, demo })); +} + +export async function listEnvs(appId: string) { + const response = await authRequest(`/bootstrap`); + return response.org.apps.find(({ id }) => id === appId)?.environments ?? []; +} diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 45cec604..5eb364e0 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -1,53 +1,55 @@ -import { AxiosError } from "axios"; -import chalk from "chalk"; - import { authRequest } from "../utils/auth.js"; -import { genDTS } from "../utils/gen.js"; +import { genDTS, genFeatureKey } from "../utils/gen.js"; type Feature = { + id: string; name: string; - createdAt: string; + key: string; }; -export async function listFeatures(appId: string): Promise { - try { - const response = await authRequest( - `/apps/${appId}/features?envId=enPa3R6khIKcWA`, - ); - return response.data.map(({ name, createdAt }: Feature) => ({ - name, - createdAt, - })); - } catch (error) { - if (error instanceof AxiosError && error.response) { - console.dir(error.response.data, { depth: null }); - } - throw error; - } -} +type FeatureNamesResponse = Feature[]; -export async function createFeature() { - // Implement feature creation logic here - console.log("Feature creation not implemented yet."); +export async function listFeatures(appId: string) { + const response = await authRequest( + `/apps/${appId}/features/names`, + ); + return response.map(({ name, key }) => ({ + name, + key, + })); } export async function genFeatureTypes(appId: string) { - try { - const response = await authRequest( - `/apps/${appId}/features?envId=enPa3R6khIKcWA`, - ); - return genDTS( - response.data.map(({ flagKey }: { flagKey: string }) => flagKey), - ) as string; - } catch (error) { - if (error instanceof AxiosError && error.response) { - console.error(chalk.red("Authentication failed."), error.response.data); - } - throw error; - } + const response = await authRequest( + `/apps/${appId}/features/names`, + ); + return genDTS(response.map(({ key }) => key)); } -export async function rolloutFeature() { - // Implement feature rollout logic here - console.log("Feature rollout not implemented yet."); +type FeatureResponse = { + feature: Feature; +}; + +export async function createFeature( + appId: string, + envId: string, + name: string, + key: string | undefined, +) { + const features = await listFeatures(appId); + const response = await authRequest( + `/apps/${appId}/features?envId=${envId}`, + { + method: "POST", + data: { + name, + key: genFeatureKey( + key ?? name, + features.map(({ key }) => key), + ), + source: "event", + }, + }, + ); + return response.feature; } diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 2ea5c9d1..87b02b24 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,9 +1,8 @@ import http from "http"; import axios, { AxiosError, AxiosRequestConfig } from "axios"; -import chalk from "chalk"; import open from "open"; -import { readConfig, writeConfig } from "./config.js"; +import { getConfig, writeConfigFile } from "./config.js"; import { API_BASE_URL } from "./constants.js"; /** @@ -15,21 +14,22 @@ export async function authenticateUser() { const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname !== "/") { - res.writeHead(404).end("Not Found"); + res.writeHead(404).end("Invalid path"); server.close(); - reject(new Error("Authentication failed")); + reject(new Error("Could not authenticate: Invalid path")); return; } if (!req.headers.cookie?.includes("session.sig")) { - res.writeHead(400).end("No session cookie found"); + res.writeHead(400).end("Could not authenticate"); server.close(); - reject(new Error("Authentication failed: No session cookie")); + reject(new Error("Could not authenticate")); return; } - res.end("OK"); + res.end("You can now close this tab."); server.close(); + writeConfigFile("sessionCookies", req.headers.cookie); resolve(req.headers.cookie); }); @@ -45,38 +45,18 @@ export async function authenticateUser() { }); } -let sessionCookies = ""; - export function checkAuth() { - if (!sessionCookies) { - console.log( - chalk.red( - 'You are not authenticated. Please run "bucket auth login" first.', - ), + if (!getConfig("sessionCookies")) { + throw new Error( + 'You are not authenticated. Please run "bucket auth login" first.', ); - process.exit(1); } } -export async function saveSessionCookie(cookies: string) { - await writeConfig("sessionCookies", cookies); - sessionCookies = cookies; -} - -export async function loadSessionCookie() { - sessionCookies = await readConfig("sessionCookies"); -} - -export function removeSessionCookie() { - saveSessionCookie(""); - sessionCookies = ""; -} - -export function getSessionCookie() { - return sessionCookies; -} - -export async function authRequest(url: string, options?: AxiosRequestConfig) { +export async function authRequest>( + url: string, + options?: AxiosRequestConfig, +): Promise { checkAuth(); try { const response = await axios({ @@ -84,7 +64,7 @@ export async function authRequest(url: string, options?: AxiosRequestConfig) { url: `${API_BASE_URL}${url}`, headers: { ...options?.headers, - Cookie: sessionCookies, + Cookie: getConfig("sessionCookies"), }, }); return response.data; @@ -94,9 +74,9 @@ export async function authRequest(url: string, options?: AxiosRequestConfig) { error.response && error.response.status === 401 ) { - console.log(chalk.red("Your session has expired. Please login again.")); - removeSessionCookie(); - process.exit(1); + writeConfigFile("sessionCookies", undefined); + error.message = "Your session has expired. Please login again."; + throw error; } throw error; } diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 1e6cf21c..7579cb38 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -2,12 +2,33 @@ import { readJson, writeJson } from "fs-extra/esm"; import { CONFIG_FILE } from "./constants.js"; +type Config = { + sessionCookies?: string; + appId?: string; + envId?: string; +}; + +let config: Config = {}; + +/** + * Instantly return a specified key's value or the entire config object. + */ +export function getConfig(): Config; +export function getConfig(key: keyof Config): string | undefined; +export function getConfig(key?: keyof Config) { + return key ? config[key] : config; +} + /** - * Read a value from the config file. + * Read the config file and return either a specified key's value or the entire config object. */ -export async function readConfig(key?: string) { +export async function readConfigFile(): Promise; +export async function readConfigFile( + key: keyof Config, +): Promise; +export async function readConfigFile(key?: keyof Config) { try { - const config = await readJson(CONFIG_FILE); + config = await readJson(CONFIG_FILE); return key ? config[key] : config; } catch (error) { return {}; @@ -17,8 +38,11 @@ export async function readConfig(key?: string) { /** * Write a new value to the config file. */ -export async function writeConfig(key: string, value: string) { - const config = await readConfig(); +export async function writeConfigFile( + key: keyof Config, + value: string | undefined, +) { + const config = await readConfigFile(); config[key] = value; await writeJson(CONFIG_FILE, config); } diff --git a/packages/cli/utils/error.ts b/packages/cli/utils/error.ts new file mode 100644 index 00000000..7867e562 --- /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) { + 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 }: { path: string[]; message: string }) => ({ + path: path.join("."), + error: message, + }), + ), + ); + } + } 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.ts b/packages/cli/utils/gen.ts index 20de7302..ec672dea 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -1,6 +1,9 @@ +import slugify from "@sindresorhus/slugify"; + export const genDTS = (keys: string[]) => { return /* ts */ ` -// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET-CLI AND WILL BE OVERWRITTEN. +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + const availableFeatures = [ "${keys.join('",\n "')}" ] as const; @@ -16,3 +19,21 @@ declare module "@bucketco/react-sdk" { } `.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/yarn.lock b/yarn.lock index 24972d3b..9f45b601 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3755,6 +3755,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" @@ -5776,6 +5795,7 @@ __metadata: "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@inquirer/prompts": "npm:^5.3.8" + "@sindresorhus/slugify": "npm:^2.2.1" "@types/fs-extra": "npm:^11.0.4" "@types/node": "npm:^22.5.1" axios: "npm:^1.7.5" @@ -5783,6 +5803,8 @@ __metadata: commander: "npm:^12.1.0" fs-extra: "npm:^11.2.0" open: "npm:^10.1.0" + slug: "npm:^9.1.0" + slugify: "npm:^1.6.6" ts-node: "npm:^10.9.2" typescript: "npm:^5.5.4" bin: @@ -7658,6 +7680,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" @@ -14529,6 +14558,22 @@ __metadata: languageName: node linkType: hard +"slug@npm:^9.1.0": + version: 9.1.0 + resolution: "slug@npm:9.1.0" + bin: + slug: cli.js + checksum: 10c0/edc3e957de63e2d78d3809e0bf217da34ccb6ca313a83e2c1598ede2bd001644978d15eb69650a7b238c84adacbbab7318c4601c082aa433a3a2fd5e39523c2d + languageName: node + linkType: hard + +"slugify@npm:^1.6.6": + version: 1.6.6 + resolution: "slugify@npm:1.6.6" + checksum: 10c0/e7e63f08f389a371d6228bc19d64ec84360bf0a538333446cc49dbbf3971751a6d180d2f31551188dd007a65ca771e69f574e0283290a7825a818e90b75ef44d + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" From 174c0f9ee8b399e844372d558f93c4bfc2f15fa3 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Sat, 31 Aug 2024 11:44:02 +0100 Subject: [PATCH 03/60] feat: added spinners --- packages/cli/commands/apps.ts | 9 +- packages/cli/commands/auth.ts | 16 +++- packages/cli/commands/envs.ts | 9 +- packages/cli/commands/features.ts | 10 +++ packages/cli/package.json | 3 +- yarn.lock | 136 +++++++++++++++++++++++++----- 6 files changed, 156 insertions(+), 27 deletions(-) diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index d7f70ad0..136a65ea 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -1,6 +1,7 @@ import { select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command } from "commander"; +import ora from "ora"; import { listApps } from "../services/bootstrap.js"; import { checkAuth } from "../utils/auth.js"; @@ -14,12 +15,15 @@ export function registerAppsCommands(program: Command) { .command("list") .description("List all available apps") .action(async () => { + const spinner = ora("Loading apps...").start(); checkAuth(); try { const apps = await listApps(); + spinner.succeed(); console.log(chalk.green("Available apps:")); console.table(apps); } catch (error) { + spinner.fail(); handleError(error, "Failed to list apps:"); } }); @@ -28,10 +32,11 @@ export function registerAppsCommands(program: Command) { .command("select") .description("Select app") .action(async () => { + const spinner = ora("Loading apps...").start(); checkAuth(); try { const apps = await listApps(); - + spinner.succeed(); const answer = await select({ message: "Select an app", choices: apps.map((app) => ({ @@ -40,9 +45,9 @@ export function registerAppsCommands(program: Command) { description: app.demo ? "Demo" : undefined, })), }); - await writeConfigFile("appId", answer); } catch (error) { + spinner.fail(); handleError(error, "Failed to select app:"); } }); diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 95e585bf..5d612cfe 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import { Command } from "commander"; +import ora from "ora"; import { authenticateUser } from "../utils/auth.js"; import { writeConfigFile } from "../utils/config.js"; @@ -11,13 +12,15 @@ export function registerAuthCommands(program: Command) { authCommand .command("login") .description("Login to Bucket") - .requiredOption("-a, --appId ", "Get all features in app") .action(async () => { + const spinner = ora("Logging in...").start(); try { // Initiate the auth process await authenticateUser(); + spinner.succeed(); console.log(chalk.green("Logged in successfully!")); } catch (error) { + spinner.fail(); handleError(error, "Authentication failed:"); } }); @@ -26,8 +29,15 @@ export function registerAuthCommands(program: Command) { .command("logout") .description("Logout from Bucket") .action(async () => { - await writeConfigFile("sessionCookies", undefined); - console.log(chalk.green("Logged out successfully!")); + const spinner = ora("Logging out...").start(); + try { + await writeConfigFile("sessionCookies", undefined); + spinner.succeed(); + console.log(chalk.green("Logged out successfully!")); + } catch (error) { + spinner.fail(); + handleError(error, "Logout failed:"); + } }); program.addCommand(authCommand); diff --git a/packages/cli/commands/envs.ts b/packages/cli/commands/envs.ts index 6d428cfb..f6a978e8 100644 --- a/packages/cli/commands/envs.ts +++ b/packages/cli/commands/envs.ts @@ -1,6 +1,7 @@ import { select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command } from "commander"; +import ora from "ora"; import { listEnvs } from "../services/bootstrap.js"; import { checkAuth } from "../utils/auth.js"; @@ -20,12 +21,15 @@ export function registerEnvsCommands(program: Command) { getConfig("appId"), ) .action(async ({ appId }) => { + const spinner = ora("Loading environments...").start(); checkAuth(); try { const envs = await listEnvs(appId); + spinner.succeed(); console.log(chalk.green(`Available environments for app ${appId}:`)); console.table(envs); } catch (error) { + spinner.fail(); handleError(error, "Failed to list environment:"); } }); @@ -39,10 +43,11 @@ export function registerEnvsCommands(program: Command) { getConfig("appId"), ) .action(async ({ appId }) => { + const spinner = ora("Loading environments...").start(); checkAuth(); try { const envs = await listEnvs(appId); - + spinner.succeed(); const answer = await select({ message: "Select an environment", choices: envs.map(({ id, name }) => ({ @@ -50,9 +55,9 @@ export function registerEnvsCommands(program: Command) { value: id, })), }); - await writeConfigFile("envId", answer); } catch (error) { + spinner.fail(); handleError(error, "Failed to select environment:"); } }); diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 73123a18..27e0773f 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; import { Command } from "commander"; import { outputFile } from "fs-extra/esm"; +import ora from "ora"; import { createFeature, @@ -26,12 +27,15 @@ export function registerFeaturesCommands(program: Command) { getConfig("appId"), ) .action(async ({ appId }) => { + const spinner = ora("Loading features...").start(); checkAuth(); try { const features = await listFeatures(appId); + spinner.succeed(); console.log(chalk.green(`Features in ${appId}:`)); console.table(features); } catch (error) { + spinner.fail(); handleError(error, "Failed to list features:"); } }); @@ -50,12 +54,15 @@ export function registerFeaturesCommands(program: Command) { GEN_TYPES_FILE, ) .action(async ({ appId, out }) => { + const spinner = ora("Generating feature types...").start(); checkAuth(); try { const types = await genFeatureTypes(appId); await outputFile(out, types); + spinner.succeed(); console.log(chalk.green(`Generated features for ${appId}.`)); } catch (error) { + spinner.fail(); handleError(error, "Failed to generate feature types:"); } }); @@ -79,15 +86,18 @@ export function registerFeaturesCommands(program: Command) { `Create a feature in the app with the given feature key. Falls back to a slug of the feature name.`, ) .action(async (name, { appId, envId, key }) => { + const spinner = ora("Creating feature...").start(); checkAuth(); try { const feature = await createFeature(appId, envId, name, key); + spinner.succeed(); console.log( chalk.green( `Created feature ${feature.name} with key ${feature.key}.`, ), ); } catch (error) { + spinner.fail(); handleError(error, "Failed to create feature:"); } }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 7a26b9ae..b84c4adf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,8 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "fs-extra": "^11.2.0", - "open": "^10.1.0" + "open": "^10.1.0", + "ora": "^8.1.0" }, "devDependencies": { "@bucketco/eslint-config": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 9f45b601..100bea22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5803,8 +5803,7 @@ __metadata: commander: "npm:^12.1.0" fs-extra: "npm:^11.2.0" open: "npm:^10.1.0" - slug: "npm:^9.1.0" - slugify: "npm:^1.6.6" + ora: "npm:^8.1.0" ts-node: "npm:^10.9.2" typescript: "npm:^5.5.4" bin: @@ -6216,6 +6215,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" @@ -7239,6 +7247,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" @@ -8526,6 +8541,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" @@ -9669,6 +9691,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" @@ -9881,6 +9910,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" @@ -10685,6 +10728,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" @@ -11038,6 +11091,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" @@ -12163,6 +12223,15 @@ __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" @@ -12242,6 +12311,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" @@ -13945,6 +14031,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" @@ -14558,22 +14654,6 @@ __metadata: languageName: node linkType: hard -"slug@npm:^9.1.0": - version: 9.1.0 - resolution: "slug@npm:9.1.0" - bin: - slug: cli.js - checksum: 10c0/edc3e957de63e2d78d3809e0bf217da34ccb6ca313a83e2c1598ede2bd001644978d15eb69650a7b238c84adacbbab7318c4601c082aa433a3a2fd5e39523c2d - languageName: node - linkType: hard - -"slugify@npm:^1.6.6": - version: 1.6.6 - resolution: "slugify@npm:1.6.6" - checksum: 10c0/e7e63f08f389a371d6228bc19d64ec84360bf0a538333446cc49dbbf3971751a6d180d2f31551188dd007a65ca771e69f574e0283290a7825a818e90b75ef44d - languageName: node - linkType: hard - "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -14779,6 +14859,13 @@ __metadata: 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" @@ -14831,6 +14918,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" @@ -14965,7 +15063,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: From 471364029c00780f3d99d32dc0f57fc254e4f958 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 9 Dec 2024 19:24:00 +0100 Subject: [PATCH 04/60] updated Cli login mechanism --- packages/cli/commands/auth.ts | 2 +- packages/cli/utils/auth.ts | 32 ++++++++++++++++++++++---------- packages/cli/utils/config.ts | 2 +- packages/cli/utils/constants.ts | 7 +++++-- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 5d612cfe..f7520340 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -31,7 +31,7 @@ export function registerAuthCommands(program: Command) { .action(async () => { const spinner = ora("Logging out...").start(); try { - await writeConfigFile("sessionCookies", undefined); + await writeConfigFile("token", undefined); spinner.succeed(); console.log(chalk.green("Logged out successfully!")); } catch (error) { diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 87b02b24..cee53f8c 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -3,7 +3,7 @@ import axios, { AxiosError, AxiosRequestConfig } from "axios"; import open from "open"; import { getConfig, writeConfigFile } from "./config.js"; -import { API_BASE_URL } from "./constants.js"; +import { API_BASE_URL, loginUrl } from "./constants.js"; /** * @return {Promise} @@ -13,32 +13,44 @@ export async function authenticateUser() { const server = http.createServer((req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); - if (url.pathname !== "/") { + if (url.pathname !== "/cli-login") { res.writeHead(404).end("Invalid path"); server.close(); reject(new Error("Could not authenticate: Invalid path")); return; } - if (!req.headers.cookie?.includes("session.sig")) { + // Handle preflight request + if (req.method === "OPTIONS") { + res.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "Authorization", + }); + res.end(); + return; + } + + if (!req.headers.authorization?.startsWith("Bearer ")) { res.writeHead(400).end("Could not authenticate"); server.close(); reject(new Error("Could not authenticate")); return; } + const token = req.headers.authorization.slice("Bearer ".length); + res.end("You can now close this tab."); server.close(); - writeConfigFile("sessionCookies", req.headers.cookie); - resolve(req.headers.cookie); + writeConfigFile("token", token); + resolve(token); }); server.listen(); const address = server.address(); if (address && typeof address === "object") { const port = address.port; - const redirect = `http://localhost:${port}`; - open(`${API_BASE_URL}/auth/google?redirect=${redirect}`, { + open(loginUrl(port), { newInstance: true, }); } @@ -46,7 +58,7 @@ export async function authenticateUser() { } export function checkAuth() { - if (!getConfig("sessionCookies")) { + if (!getConfig("token")) { throw new Error( 'You are not authenticated. Please run "bucket auth login" first.', ); @@ -64,7 +76,7 @@ export async function authRequest>( url: `${API_BASE_URL}${url}`, headers: { ...options?.headers, - Cookie: getConfig("sessionCookies"), + Authorization: "Bearer " + getConfig("token"), }, }); return response.data; @@ -74,7 +86,7 @@ export async function authRequest>( error.response && error.response.status === 401 ) { - writeConfigFile("sessionCookies", undefined); + writeConfigFile("token", undefined); error.message = "Your session has expired. Please login again."; throw error; } diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 7579cb38..539cdf13 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -3,7 +3,7 @@ import { readJson, writeJson } from "fs-extra/esm"; import { CONFIG_FILE } from "./constants.js"; type Config = { - sessionCookies?: string; + token?: string; appId?: string; envId?: string; }; diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 3cc0cfa3..534b6cce 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,7 +1,10 @@ import path from "path"; -export const API_BASE_URL = - "http://localhost:3100/api" ?? "https://app.bucket.co/api"; +const baseUrl = process.env.BUCKET_BASE_URL ?? "https://app.bucket.co"; +export const loginUrl = (localPort: number) => + `${baseUrl}/login?redirect_url=` + + encodeURIComponent("/cli-login?port=" + localPort); +export const API_BASE_URL = `${baseUrl}/api`; export const CONFIG_FILE = path.join( process.env.HOME || "", From 88fc072f2bd04a43cb46abf617a1d07449f9c9ef Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 19 Feb 2025 08:54:00 +0100 Subject: [PATCH 05/60] fix auth flow, remove envId --- packages/cli/commands/features.ts | 9 ++---- packages/cli/package.json | 2 +- packages/cli/services/features.ts | 19 ++++++------ packages/cli/utils/auth.ts | 49 ++++++++++++++++++++++++------- packages/cli/utils/constants.ts | 2 +- packages/cli/utils/gen.ts | 14 ++------- yarn.lock | 46 ++++++++++++++--------------- 7 files changed, 78 insertions(+), 63 deletions(-) diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 27e0773f..81d3cb1c 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -76,20 +76,15 @@ export function registerFeaturesCommands(program: Command) { `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, getConfig("appId"), ) - .requiredOption( - "-e, --envId ", - `Get all features in the env. Falls back to envId stored in ${CONFIG_FILE}.`, - getConfig("envId"), - ) .option( "-k, --key ", `Create a feature in the app with the given feature key. Falls back to a slug of the feature name.`, ) - .action(async (name, { appId, envId, key }) => { + .action(async (name, { appId, key }) => { const spinner = ora("Creating feature...").start(); checkAuth(); try { - const feature = await createFeature(appId, envId, name, key); + const feature = await createFeature(appId, name, key); spinner.succeed(); console.log( chalk.green( diff --git a/packages/cli/package.json b/packages/cli/package.json index b84c4adf..d33fb850 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,5 +1,5 @@ { - "name": "bucket-cli", + "name": "@bucketco/cli", "version": "1.0.0", "packageManager": "yarn@4.1.1", "description": "CLI for Bucket service", diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 5eb364e0..c897ad8c 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -2,27 +2,27 @@ import { authRequest } from "../utils/auth.js"; import { genDTS, genFeatureKey } from "../utils/gen.js"; type Feature = { - id: string; name: string; key: string; }; -type FeatureNamesResponse = Feature[]; +type FeaturesResponse = { + data: Feature[]; +}; export async function listFeatures(appId: string) { - const response = await authRequest( - `/apps/${appId}/features/names`, + const response = await authRequest( + `/apps/${appId}/features`, ); - return response.map(({ name, key }) => ({ + + return response.data.map(({ name, key }) => ({ name, key, })); } export async function genFeatureTypes(appId: string) { - const response = await authRequest( - `/apps/${appId}/features/names`, - ); + const response = await listFeatures(appId); return genDTS(response.map(({ key }) => key)); } @@ -32,13 +32,12 @@ type FeatureResponse = { export async function createFeature( appId: string, - envId: string, name: string, key: string | undefined, ) { const features = await listFeatures(appId); const response = await authRequest( - `/apps/${appId}/features?envId=${envId}`, + `/apps/${appId}/features`, { method: "POST", data: { diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index cee53f8c..e0c58ea9 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,17 +1,40 @@ -import http from "http"; +import http, { IncomingMessage } from "http"; import axios, { AxiosError, AxiosRequestConfig } from "axios"; import open from "open"; import { getConfig, writeConfigFile } from "./config.js"; import { API_BASE_URL, loginUrl } from "./constants.js"; +function readBody(req: IncomingMessage) { + return new Promise((resolve) => { + let bodyChunks: any = []; + + req.on("data", (chunk) => { + bodyChunks.push(chunk); + }); + req.on("end", () => { + resolve(Buffer.concat(bodyChunks).toString()); + }); + }); +} + +function corsHeaders(origin: string): Record { + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "Authorization", + }; +} + /** * @return {Promise} */ export async function authenticateUser() { return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { + const server = http.createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); + const origin = new URL(loginUrl(0)).origin; + const headers = corsHeaders(origin); if (url.pathname !== "/cli-login") { res.writeHead(404).end("Invalid path"); @@ -22,17 +45,13 @@ export async function authenticateUser() { // Handle preflight request if (req.method === "OPTIONS") { - res.writeHead(200, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST", - "Access-Control-Allow-Headers": "Authorization", - }); + res.writeHead(200, corsHeaders(origin)); res.end(); return; } if (!req.headers.authorization?.startsWith("Bearer ")) { - res.writeHead(400).end("Could not authenticate"); + res.writeHead(400, headers).end("Could not authenticate"); server.close(); reject(new Error("Could not authenticate")); return; @@ -40,9 +59,19 @@ export async function authenticateUser() { const token = req.headers.authorization.slice("Bearer ".length); - res.end("You can now close this tab."); + const body = JSON.parse(await readBody(req)); + + if (body.defaultAppId !== undefined && getConfig("appId") === undefined) { + await writeConfigFile("appId", body.defaultAppId); + } + + headers["Content-Type"] = "application/json"; + + res.writeHead(200, headers); + res.end(JSON.stringify({ result: "success" })); server.close(); - writeConfigFile("token", token); + + await writeConfigFile("token", token); resolve(token); }); diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 534b6cce..25d01ad0 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -4,7 +4,7 @@ const baseUrl = process.env.BUCKET_BASE_URL ?? "https://app.bucket.co"; export const loginUrl = (localPort: number) => `${baseUrl}/login?redirect_url=` + encodeURIComponent("/cli-login?port=" + localPort); -export const API_BASE_URL = `${baseUrl}/api`; +export const API_BASE_URL = process.env.BUCKET_API_URL ?? `${baseUrl}/api`; export const CONFIG_FILE = path.join( process.env.HOME || "", diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index ec672dea..47d74b94 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -4,18 +4,10 @@ export const genDTS = (keys: string[]) => { return /* ts */ ` // DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. -const availableFeatures = [ - "${keys.join('",\n "')}" -] as const; - -type ArrayToRecord = { - [Key in T[number]]: boolean; -}; - -export type AvailableFeatures = ArrayToRecord; - declare module "@bucketco/react-sdk" { - interface Features extends AvailableFeatures {} + interface Features { +${keys.map((key) => ` ${key}: boolean;`).join("\n")} + } } `.trim(); }; diff --git a/yarn.lock b/yarn.lock index 100bea22..08e15d29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -926,6 +926,29 @@ __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:^5.3.8" + "@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" + fs-extra: "npm:^11.2.0" + open: "npm:^10.1.0" + ora: "npm:^8.1.0" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.5.4" + 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" @@ -5788,29 +5811,6 @@ __metadata: languageName: node linkType: hard -"bucket-cli@workspace:packages/cli": - version: 0.0.0-use.local - resolution: "bucket-cli@workspace:packages/cli" - dependencies: - "@bucketco/eslint-config": "workspace:^" - "@bucketco/tsconfig": "workspace:^" - "@inquirer/prompts": "npm:^5.3.8" - "@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" - fs-extra: "npm:^11.2.0" - open: "npm:^10.1.0" - ora: "npm:^8.1.0" - ts-node: "npm:^10.9.2" - typescript: "npm:^5.5.4" - bin: - bucket: ./dist/index.js - languageName: unknown - linkType: soft - "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" From d6d758fb62b140f5d283aa1ffd0a64d05b58bbb2 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 19 Feb 2025 08:56:39 +0100 Subject: [PATCH 06/60] make this a pre-1.0 release --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index d33fb850..c5a23993 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/cli", - "version": "1.0.0", + "version": "0.0.1", "packageManager": "yarn@4.1.1", "description": "CLI for Bucket service", "main": "./dist/index.js", From cf1b15f252a825a72d59163d54fcea7cb9e0f202 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Fri, 21 Feb 2025 16:58:00 +0100 Subject: [PATCH 07/60] feat: more work on CLI --- packages/cli/bucket.config.json | 8 + packages/cli/commands/apps.ts | 56 ++--- packages/cli/commands/auth.ts | 61 +++-- packages/cli/commands/envs.ts | 66 ------ packages/cli/commands/features.ts | 134 ++++++----- packages/cli/commands/init.ts | 92 ++++++++ packages/cli/index.ts | 42 +++- packages/cli/package.json | 9 +- packages/cli/schema.json | 32 +++ packages/cli/services/bootstrap.ts | 29 +-- packages/cli/utils/auth.ts | 88 ++++--- packages/cli/utils/config.ts | 100 ++++++-- packages/cli/utils/constants.ts | 28 +-- packages/cli/utils/options.ts | 10 + yarn.lock | 363 ++++++++++++++++++++++++----- 15 files changed, 773 insertions(+), 345 deletions(-) create mode 100644 packages/cli/bucket.config.json delete mode 100644 packages/cli/commands/envs.ts create mode 100644 packages/cli/commands/init.ts create mode 100644 packages/cli/schema.json create mode 100644 packages/cli/utils/options.ts diff --git a/packages/cli/bucket.config.json b/packages/cli/bucket.config.json new file mode 100644 index 00000000..53fedd24 --- /dev/null +++ b/packages/cli/bucket.config.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://unpkg.com/@bucketco/cli@0.0.1/schema.json", + "baseUrl": "https://staging.app.bucket.co", + "apiUrl": "https://staging.app.bucket.co/api", + "appId": "aptC9gNxk8u7IE", + "typesPath": "gen/features.ts", + "keyFormat": "custom" +} diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index 136a65ea..394b8fb2 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -1,56 +1,30 @@ -import { select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command } from "commander"; import ora from "ora"; import { listApps } from "../services/bootstrap.js"; -import { checkAuth } from "../utils/auth.js"; -import { writeConfigFile } from "../utils/config.js"; import { handleError } from "../utils/error.js"; -export function registerAppsCommands(program: Command) { +export const listAppsAction = async () => { + const spinner = ora("Loading apps...").start(); + try { + const apps = await listApps(); + spinner.succeed(); + console.log(chalk.green("Available apps:")); + console.table(apps); + } catch (error) { + spinner.fail(); + handleError(error, "Failed to list apps:"); + } +}; + +export function registerAppCommands(program: Command) { const appsCommand = new Command("apps").description("Manage apps"); appsCommand .command("list") .description("List all available apps") - .action(async () => { - const spinner = ora("Loading apps...").start(); - checkAuth(); - try { - const apps = await listApps(); - spinner.succeed(); - console.log(chalk.green("Available apps:")); - console.table(apps); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to list apps:"); - } - }); - - appsCommand - .command("select") - .description("Select app") - .action(async () => { - const spinner = ora("Loading apps...").start(); - checkAuth(); - try { - const apps = await listApps(); - spinner.succeed(); - const answer = await select({ - message: "Select an app", - choices: apps.map((app) => ({ - name: app.name, - value: app.id, - description: app.demo ? "Demo" : undefined, - })), - }); - await writeConfigFile("appId", answer); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to select app:"); - } - }); + .action(listAppsAction); program.addCommand(appsCommand); } diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index f7520340..cb9d3e1b 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -2,43 +2,38 @@ import chalk from "chalk"; import { Command } from "commander"; import ora from "ora"; -import { authenticateUser } from "../utils/auth.js"; -import { writeConfigFile } from "../utils/config.js"; +import { authenticateUser, storeToken } from "../utils/auth.js"; import { handleError } from "../utils/error.js"; -export function registerAuthCommands(program: Command) { - const authCommand = new Command("auth").description("Manage authentication"); +export const loginAction = async () => { + const spinner = ora("Logging in...").start(); + try { + await authenticateUser(); + spinner.succeed(); + console.log(chalk.green("Logged in successfully!")); + } catch (error) { + spinner.fail(); + handleError(error, "Authentication failed:"); + } +}; + +export const logoutAction = async () => { + const spinner = ora("Logging out...").start(); + try { + await storeToken(""); + spinner.succeed(); + console.log(chalk.green("Logged out successfully!")); + } catch (error) { + spinner.fail(); + handleError(error, "Logout failed:"); + } +}; - authCommand - .command("login") - .description("Login to Bucket") - .action(async () => { - const spinner = ora("Logging in...").start(); - try { - // Initiate the auth process - await authenticateUser(); - spinner.succeed(); - console.log(chalk.green("Logged in successfully!")); - } catch (error) { - spinner.fail(); - handleError(error, "Authentication failed:"); - } - }); +export function registerAuthCommands(program: Command) { + program.command("login").description("Login to Bucket").action(loginAction); - authCommand + program .command("logout") .description("Logout from Bucket") - .action(async () => { - const spinner = ora("Logging out...").start(); - try { - await writeConfigFile("token", undefined); - spinner.succeed(); - console.log(chalk.green("Logged out successfully!")); - } catch (error) { - spinner.fail(); - handleError(error, "Logout failed:"); - } - }); - - program.addCommand(authCommand); + .action(logoutAction); } diff --git a/packages/cli/commands/envs.ts b/packages/cli/commands/envs.ts deleted file mode 100644 index f6a978e8..00000000 --- a/packages/cli/commands/envs.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { select } from "@inquirer/prompts"; -import chalk from "chalk"; -import { Command } from "commander"; -import ora from "ora"; - -import { listEnvs } from "../services/bootstrap.js"; -import { checkAuth } from "../utils/auth.js"; -import { getConfig, writeConfigFile } from "../utils/config.js"; -import { CONFIG_FILE } from "../utils/constants.js"; -import { handleError } from "../utils/error.js"; - -export function registerEnvsCommands(program: Command) { - const envsCommand = new Command("envs").description("Manage envs"); - - envsCommand - .command("list") - .description("List all available environments for the app") - .requiredOption( - "-a, --appId ", - `Get all environments for the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) - .action(async ({ appId }) => { - const spinner = ora("Loading environments...").start(); - checkAuth(); - try { - const envs = await listEnvs(appId); - spinner.succeed(); - console.log(chalk.green(`Available environments for app ${appId}:`)); - console.table(envs); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to list environment:"); - } - }); - - envsCommand - .command("select") - .description("Select environment") - .requiredOption( - "-a, --appId ", - `Get all environments for the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) - .action(async ({ appId }) => { - const spinner = ora("Loading environments...").start(); - checkAuth(); - try { - const envs = await listEnvs(appId); - spinner.succeed(); - const answer = await select({ - message: "Select an environment", - choices: envs.map(({ id, name }) => ({ - name, - value: id, - })), - }); - await writeConfigFile("envId", answer); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to select environment:"); - } - }); - - program.addCommand(envsCommand); -} diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 81d3cb1c..3a500dbf 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -8,12 +8,73 @@ import { genFeatureTypes, listFeatures, } from "../services/features.js"; -import { checkAuth } from "../utils/auth.js"; import { getConfig } from "../utils/config.js"; -import { CONFIG_FILE, GEN_TYPES_FILE } from "../utils/constants.js"; +import { + CONFIG_FILE_NAME, + DEFAULT_TYPES_PATH as DEFAULT_TYPES_PATH, +} from "../utils/constants.js"; import { handleError } from "../utils/error.js"; +import { appIdOption } from "../utils/options.js"; + +type AppIdArgs = { + appId: string; +}; + +export const listFeaturesAction = async ({ appId }: AppIdArgs) => { + const spinner = ora("Loading features...").start(); + try { + const features = await listFeatures(appId); + spinner.succeed(); + console.log(chalk.green(`Features in ${appId}:`)); + console.table(features); + } catch (error) { + spinner.fail(); + handleError(error, "Failed to list features:"); + } +}; + +type GenerateTypesArgs = AppIdArgs & { + out: string; +}; + +export const generateTypesAction = async ({ + appId, + out, +}: GenerateTypesArgs) => { + const spinner = ora("Generating feature types...").start(); + try { + const types = await genFeatureTypes(appId); + await outputFile(out, types); + spinner.succeed(); + console.log(chalk.green(`Generated features for ${appId}.`)); + } catch (error) { + spinner.fail(); + handleError(error, "Failed to generate feature types:"); + } +}; -export function registerFeaturesCommands(program: Command) { +type CreateFeatureArgs = AppIdArgs & { + key?: string; +}; + +export const createFeatureAction = async ( + name: string, + { appId, key }: CreateFeatureArgs, +) => { + const spinner = ora("Creating feature...").start(); + try { + const feature = await createFeature(appId, name, key); + spinner.succeed(); + console.log( + chalk.green(`Created feature ${feature.name} with key ${feature.key}.`), + ); + } catch (error) { + spinner.fail(); + handleError(error, "Failed to create feature:"); + } +}; + +export function registerFeatureCommands(program: Command) { const featuresCommand = new Command("features").description( "Manage features", ); @@ -21,81 +82,30 @@ export function registerFeaturesCommands(program: Command) { featuresCommand .command("list") .description("List all features") - .requiredOption( - "-a, --appId ", - `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) - .action(async ({ appId }) => { - const spinner = ora("Loading features...").start(); - checkAuth(); - try { - const features = await listFeatures(appId); - spinner.succeed(); - console.log(chalk.green(`Features in ${appId}:`)); - console.table(features); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to list features:"); - } - }); + .addOption(appIdOption) + .action(listFeaturesAction); featuresCommand .command("types") .description("Generate feature types") - .requiredOption( - "-a, --appId ", - `Generate types for features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) + .addOption(appIdOption) .option( "-o, --out ", - `Generate types for features at the output path. Falls back to ${GEN_TYPES_FILE}.`, - GEN_TYPES_FILE, + `Generate types for features at the output path. Falls back to typePath stored in ${CONFIG_FILE_NAME} or ${DEFAULT_TYPES_PATH}.`, + getConfig("typesPath") ?? DEFAULT_TYPES_PATH, ) - .action(async ({ appId, out }) => { - const spinner = ora("Generating feature types...").start(); - checkAuth(); - try { - const types = await genFeatureTypes(appId); - await outputFile(out, types); - spinner.succeed(); - console.log(chalk.green(`Generated features for ${appId}.`)); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to generate feature types:"); - } - }); + .action(generateTypesAction); featuresCommand .command("create") .description("Create a new feature") .argument("", "Name of the feature") - .requiredOption( - "-a, --appId ", - `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) + .addOption(appIdOption) .option( "-k, --key ", `Create a feature in the app with the given feature key. Falls back to a slug of the feature name.`, ) - .action(async (name, { appId, key }) => { - const spinner = ora("Creating feature...").start(); - checkAuth(); - try { - const feature = await createFeature(appId, name, key); - spinner.succeed(); - console.log( - chalk.green( - `Created feature ${feature.name} with key ${feature.key}.`, - ), - ); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to create feature:"); - } - }); + .action(createFeatureAction); program.addCommand(featuresCommand); } diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts new file mode 100644 index 00000000..1763a572 --- /dev/null +++ b/packages/cli/commands/init.ts @@ -0,0 +1,92 @@ +import { input, select } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command, program } from "commander"; +import ora from "ora"; + +import { listApps } from "../services/bootstrap.js"; +import { createConfigFile } from "../utils/config.js"; + +import { DEFAULT_TYPES_PATH } from "../utils/constants.js"; +import { handleError } from "../utils/error.js"; + +type InitArgs = { + force?: boolean; +}; + +export const initAction = async (args: InitArgs) => { + console.log(chalk.magenta("\nWelcome to Bucket! 🪣\n")); + const { baseUrl, apiUrl } = program.opts(); + + try { + // Check if already authenticated + let spinner = ora(`Authenticating with ${baseUrl}...`).start(); + const apps = await listApps(); + spinner.succeed("Authenticated"); + + let appId: string | undefined; + + const nonDemoApps = apps.filter((app) => !app.demo); + + // If there is only one non-demo app, select it automatically + if (nonDemoApps.length === 1) { + appId = nonDemoApps[0].id; + console.log( + chalk.gray( + `Automatically selected app ${nonDemoApps[0].name} (${appId})`, + ), + ); + } else { + appId = await select({ + message: "Select an app", + choices: apps.map((app) => ({ + name: app.name, + value: app.id, + description: app.demo ? "Demo" : undefined, + })), + }); + } + + const keyFormat = + apps.find((app) => app.id === appId)?.featureKeyFormat ?? "custom"; + + // Get types output path + const typesPath = await input({ + message: "Where should we generate the types?", + default: DEFAULT_TYPES_PATH, + }); + + // Create config file + spinner = ora("Creating configuration...").start(); + await createConfigFile( + { + baseUrl, + apiUrl, + appId, + typesPath, + keyFormat, + }, + args.force, + ); + spinner.succeed("Configuration created"); + + console.log(chalk.green("\nBucket initialized successfully! 🎉")); + console.log( + chalk.gray( + "\nNext steps:\n1. Run 'bucket features sync' to sync your feature flags\n2. Import the generated types in your code", + ), + ); + } catch (error) { + handleError(error, "Initialization failed:"); + } +}; + +export function registerInitCommand(program: Command) { + program + .command("init") + .description("Initialize a new Bucket configuration") + .option( + "-f, --force", + "Force initialization overwriting existing configuration", + ) + .action(initAction); +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts index c5856819..c64ef661 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,21 +1,45 @@ -#!/usr/bin/env node +#!/usr/bin/env node --no-warnings=ExperimentalWarning import { program } from "commander"; -import { registerAppsCommands } from "./commands/apps.js"; import { registerAuthCommands } from "./commands/auth.js"; -import { registerEnvsCommands } from "./commands/envs.js"; -import { registerFeaturesCommands } from "./commands/features.js"; -import { readConfigFile } from "./utils/config.js"; +import { registerFeatureCommands } from "./commands/features.js"; +import { registerInitCommand } from "./commands/init.js"; +import { getConfig, readConfigFile } from "./utils/config.js"; +import { registerAppCommands } from "./commands/apps.js"; +import chalk from "chalk"; +import { DEFAULT_API_URL, DEFAULT_BASE_URL } from "./utils/constants.js"; async function main() { // Read the config file - await readConfigFile(); + const config = await readConfigFile(); + + // Global options + program.option("--debug", "Enable debug mode", false); + program.requiredOption( + "--base-url [url]", + "Specify the Bucket base url", + getConfig("baseUrl") ?? DEFAULT_BASE_URL, + ); + program.option( + "--api-url [url]", + "Specify the Bucket API url", + getConfig("apiUrl") ?? DEFAULT_API_URL, + ); + + // Pre-action hook + program.hook("preAction", () => { + const { debug } = program.opts(); + if (debug) { + console.debug(chalk.cyan("\nDebug mode enabled")); + console.table(config); + } + }); // Main program + registerInitCommand(program); registerAuthCommands(program); - registerAppsCommands(program); - registerEnvsCommands(program); - registerFeaturesCommands(program); + registerAppCommands(program); + registerFeatureCommands(program); program.parse(process.argv); } diff --git a/packages/cli/package.json b/packages/cli/package.json index c5a23993..9fca913f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,17 +8,24 @@ "bin": { "bucket": "./dist/index.js" }, + "files": [ + "dist", + "schema.json" + ], "scripts": { "build": "tsc", - "start": "yarn build && node dist/index.js" + "bucket": "yarn build && node dist/index.js" }, "dependencies": { "@inquirer/prompts": "^5.3.8", "@sindresorhus/slugify": "^2.2.1", + "ajv": "^8.17.1", "axios": "^1.7.5", "chalk": "^5.3.0", "commander": "^12.1.0", + "find-up": "^7.0.0", "fs-extra": "^11.2.0", + "json5": "^2.2.3", "open": "^10.1.0", "ora": "^8.1.0" }, diff --git a/packages/cli/schema.json b/packages/cli/schema.json new file mode 100644 index 00000000..f45c0a6d --- /dev/null +++ b/packages/cli/schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Bucket cli schema", + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + }, + "apiUrl": { + "type": "string" + }, + "appId": { + "type": "string" + }, + "typesPath": { + "type": "string" + }, + "keyFormat": { + "type": "string", + "enum": [ + "custom", + "pascalCase", + "camelCase", + "snakeCaseUpper", + "snakeCaseLower", + "kebabCaseUpper", + "kebabCaseLower" + ] + } + }, + "required": ["appId", "typesPath", "keyFormat"] +} diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts index 0b398517..bf128773 100644 --- a/packages/cli/services/bootstrap.ts +++ b/packages/cli/services/bootstrap.ts @@ -1,31 +1,32 @@ import { authRequest } from "../utils/auth.js"; - -type Environment = { - id: string; - name: string; - isProduction: boolean; - order: number; -}; +import { KeyFormat } from "../utils/config.js"; type App = { id: string; name: string; demo: boolean; - environments: Environment[]; }; type BootstrapResponse = { org: { apps: App[]; + featureKeyFormat?: KeyFormat; }; }; export async function listApps() { const response = await authRequest(`/bootstrap`); - return response.org.apps.map(({ id, name, demo }) => ({ id, name, demo })); -} - -export async function listEnvs(appId: string) { - const response = await authRequest(`/bootstrap`); - return response.org.apps.find(({ id }) => id === appId)?.environments ?? []; + const org = response.org; + if (!org) { + throw new Error("No organization found"); + } + if (!org.apps?.length) { + throw new Error("No apps found"); + } + return response.org.apps.map(({ id, name, demo }) => ({ + id, + name, + demo, + featureKeyFormat: org.featureKeyFormat, + })); } diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index e0c58ea9..0fb10072 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,23 +1,34 @@ -import http, { IncomingMessage } from "http"; +import http from "http"; import axios, { AxiosError, AxiosRequestConfig } from "axios"; import open from "open"; -import { getConfig, writeConfigFile } from "./config.js"; -import { API_BASE_URL, loginUrl } from "./constants.js"; +import { AUTH_FILE, loginUrl } from "./constants.js"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import { dirname } from "path"; +import { program } from "commander"; -function readBody(req: IncomingMessage) { - return new Promise((resolve) => { - let bodyChunks: any = []; +export async function getToken() { + return readFile(AUTH_FILE, "utf-8"); +} - req.on("data", (chunk) => { - bodyChunks.push(chunk); - }); - req.on("end", () => { - resolve(Buffer.concat(bodyChunks).toString()); - }); - }); +export async function storeToken(newToken: string) { + await mkdir(dirname(AUTH_FILE), { recursive: true }); + await writeFile(AUTH_FILE, newToken); } +// function readBody(req: IncomingMessage) { +// return new Promise((resolve) => { +// let bodyChunks: any = []; + +// req.on("data", (chunk) => { +// bodyChunks.push(chunk); +// }); +// req.on("end", () => { +// resolve(Buffer.concat(bodyChunks).toString()); +// }); +// }); +// } + function corsHeaders(origin: string): Record { return { "Access-Control-Allow-Origin": origin, @@ -31,10 +42,11 @@ function corsHeaders(origin: string): Record { */ export async function authenticateUser() { return new Promise((resolve, reject) => { + const { baseUrl } = program.opts(); + const server = http.createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); - const origin = new URL(loginUrl(0)).origin; - const headers = corsHeaders(origin); + const headers = corsHeaders(baseUrl); if (url.pathname !== "/cli-login") { res.writeHead(404).end("Invalid path"); @@ -45,7 +57,7 @@ export async function authenticateUser() { // Handle preflight request if (req.method === "OPTIONS") { - res.writeHead(200, corsHeaders(origin)); + res.writeHead(200, headers); res.end(); return; } @@ -59,11 +71,7 @@ export async function authenticateUser() { const token = req.headers.authorization.slice("Bearer ".length); - const body = JSON.parse(await readBody(req)); - - if (body.defaultAppId !== undefined && getConfig("appId") === undefined) { - await writeConfigFile("appId", body.defaultAppId); - } + //const body = JSON.parse(await readBody(req)); headers["Content-Type"] = "application/json"; @@ -71,41 +79,45 @@ export async function authenticateUser() { res.end(JSON.stringify({ result: "success" })); server.close(); - await writeConfigFile("token", token); + await storeToken(token); resolve(token); }); + const timeout = setTimeout(() => { + server.close(); + reject(new Error("Authentication timed out after 30 seconds")); + }, 30000); + server.listen(); const address = server.address(); if (address && typeof address === "object") { const port = address.port; - open(loginUrl(port), { + open(loginUrl(baseUrl, port), { newInstance: true, }); } - }); -} -export function checkAuth() { - if (!getConfig("token")) { - throw new Error( - 'You are not authenticated. Please run "bucket auth login" first.', - ); - } + // Cleanup timeout when server closes + server.on("close", () => { + clearTimeout(timeout); + }); + }); } export async function authRequest>( url: string, options?: AxiosRequestConfig, + retryCount = 0, ): Promise { - checkAuth(); + const token = await getToken(); + const { apiUrl } = program.opts(); try { const response = await axios({ ...options, - url: `${API_BASE_URL}${url}`, + url: `${apiUrl}${url}`, headers: { ...options?.headers, - Authorization: "Bearer " + getConfig("token"), + Authorization: `Bearer ${token}`, }, }); return response.data; @@ -115,9 +127,11 @@ export async function authRequest>( error.response && error.response.status === 401 ) { - writeConfigFile("token", undefined); - error.message = "Your session has expired. Please login again."; - throw error; + await storeToken(""); + if (retryCount < 1) { + await authenticateUser(); + return authRequest(url, options, retryCount + 1); + } } throw error; } diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 539cdf13..150e6e9e 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -1,14 +1,54 @@ -import { readJson, writeJson } from "fs-extra/esm"; +import { readFile, writeFile, mkdir, access } from "fs/promises"; +import { dirname } from "path"; +import { Ajv } from "ajv"; +import JSON5 from "json5"; +import { createRequire } from "module"; -import { CONFIG_FILE } from "./constants.js"; +// https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 +const schema = createRequire(import.meta.url)("../../schema.json"); + +import { CONFIG_FILE_NAME, SCHEMA_URL } from "./constants.js"; +import { findUp } from "find-up"; + +const ajv = new Ajv(); +const validateConfig = ajv.compile(schema); + +export const keyFormats = [ + "custom", + "pascalCase", + "camelCase", + "snakeCaseUpper", + "snakeCaseLower", + "kebabCaseUpper", + "kebabCaseLower", +] as const; + +export type KeyFormat = (typeof keyFormats)[number]; + +class ConfigValidationError extends Error { + constructor(errors: typeof validateConfig.errors) { + const messages = errors + ?.map((e) => `${e.instancePath} ${e.message}`) + .join("; "); + super(`Invalid config: ${messages}`); + this.name = "ConfigValidationError"; + } +} type Config = { - token?: string; - appId?: string; - envId?: string; + $schema?: string; + baseUrl?: string; + apiUrl?: string; + appId: string; + typesPath: string; + keyFormat: KeyFormat; }; -let config: Config = {}; +let config: Config = { + appId: "", + typesPath: "", + keyFormat: "custom", +}; /** * Instantly return a specified key's value or the entire config object. @@ -28,21 +68,49 @@ export async function readConfigFile( ): Promise; export async function readConfigFile(key?: keyof Config) { try { - config = await readJson(CONFIG_FILE); + const configPath = await findUp(CONFIG_FILE_NAME); + if (!configPath) { + return {}; + } + const content = await readFile(configPath, "utf-8"); + const parsed = JSON5.parse(content); + if (!validateConfig(parsed)) { + throw new ConfigValidationError(validateConfig.errors); + } + config = parsed; return key ? config[key] : config; } catch (error) { - return {}; + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return {}; + } + throw error; } } /** - * Write a new value to the config file. + * Create a new config file with initial values. + * @param newConfig The configuration object to write + * @param overwrite If true, overwrites existing config file. Defaults to false */ -export async function writeConfigFile( - key: keyof Config, - value: string | undefined, -) { - const config = await readConfigFile(); - config[key] = value; - await writeJson(CONFIG_FILE, config); +export async function createConfigFile(newConfig: Config, overwrite = false) { + if (!validateConfig(newConfig)) { + throw new ConfigValidationError(validateConfig.errors); + } + newConfig = { $schema: SCHEMA_URL, ...newConfig }; + try { + await access(CONFIG_FILE_NAME); + if (!overwrite) { + throw new Error("Config file already exists"); + } + await writeFile(CONFIG_FILE_NAME, JSON.stringify(newConfig, null, 2)); + config = newConfig; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + await mkdir(dirname(CONFIG_FILE_NAME), { recursive: true }); + await writeFile(CONFIG_FILE_NAME, JSON.stringify(newConfig, null, 2)); + config = newConfig; + return; + } + throw error; + } } diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 25d01ad0..0f177b3e 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,18 +1,20 @@ import path from "path"; +import { createRequire } from "module"; -const baseUrl = process.env.BUCKET_BASE_URL ?? "https://app.bucket.co"; -export const loginUrl = (localPort: number) => - `${baseUrl}/login?redirect_url=` + - encodeURIComponent("/cli-login?port=" + localPort); -export const API_BASE_URL = process.env.BUCKET_API_URL ?? `${baseUrl}/api`; +// https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 +const packageJson = createRequire(import.meta.url)("../../package.json"); -export const CONFIG_FILE = path.join( - process.env.HOME || "", - ".bucket-cli-config.json", +export const CONFIG_FILE_NAME = "bucket.config.json"; +export const AUTH_FILE = path.join( + process.env.HOME ?? process.env.USERPROFILE ?? "", + ".bucket-auth", ); +export const SCHEMA_URL = `https://unpkg.com/@bucketco/cli@${packageJson.version}/schema.json`; -export const GEN_TYPES_FILE = path.join( - process.cwd(), - "gen", - "feature-flag-types.ts", -); +export const DEFAULT_BASE_URL = "https://app.bucket.co"; +export const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/api`; +export const DEFAULT_TYPES_PATH = path.join("gen", "features.ts"); + +export const loginUrl = (baseUrl: string, localPort: number) => + `${baseUrl}/login?redirect_url=` + + encodeURIComponent("/cli-login?port=" + localPort); diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts new file mode 100644 index 00000000..4b265ab2 --- /dev/null +++ b/packages/cli/utils/options.ts @@ -0,0 +1,10 @@ +import { Option } from "commander"; +import { getConfig } from "./config.js"; +import { CONFIG_FILE_NAME } from "./constants.js"; + +export const appIdOption = new Option( + "-a, --appId ", + `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE_NAME}.`, +) + .default(getConfig("appId"), "Bucket Application ID") + .makeOptionMandatory(); diff --git a/yarn.lock b/yarn.lock index 08e15d29..5b03c552 100644 --- a/yarn.lock +++ b/yarn.lock @@ -936,10 +936,13 @@ __metadata: "@sindresorhus/slugify": "npm:^2.2.1" "@types/fs-extra": "npm:^11.0.4" "@types/node": "npm:^22.5.1" + ajv: "npm:^8.17.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" + json5: "npm:^2.2.3" open: "npm:^10.1.0" ora: "npm:^8.1.0" ts-node: "npm:^10.9.2" @@ -1806,13 +1809,20 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:8.57.0, @eslint/js@npm:^8.57.0": +"@eslint/js@npm:8.57.0": version: 8.57.0 resolution: "@eslint/js@npm:8.57.0" checksum: 10c0/9a518bb8625ba3350613903a6d8c622352ab0c6557a59fe6ff6178bf882bf57123f9d92aa826ee8ac3ee74b9c6203fe630e9ee00efb03d753962dcf65ee4bd94 languageName: node linkType: hard +"@eslint/js@npm:^8.57.0": + version: 8.57.1 + resolution: "@eslint/js@npm:8.57.1" + checksum: 10c0/b489c474a3b5b54381c62e82b3f7f65f4b8a5eaaed126546520bf2fede5532a8ed53212919fed1e9048dcf7f37167c8561d58d0ba4492a4244004e7793805223 + languageName: node + linkType: hard + "@floating-ui/core@npm:^1.4.1": version: 1.4.1 resolution: "@floating-ui/core@npm:1.4.1" @@ -2573,6 +2583,13 @@ __metadata: languageName: node linkType: hard +"@nolyfill/is-core-module@npm:1.0.39": + version: 1.0.39 + resolution: "@nolyfill/is-core-module@npm:1.0.39" + checksum: 10c0/34ab85fdc2e0250879518841f74a30c276bca4f6c3e13526d2d1fe515e1adf6d46c25fcd5989d22ea056d76f7c39210945180b4859fc83b050e2da411aa86289 + languageName: node + linkType: hard + "@npmcli/agent@npm:^2.0.0": version: 2.2.2 resolution: "@npmcli/agent@npm:2.2.2" @@ -3496,6 +3513,13 @@ __metadata: languageName: node linkType: hard +"@rtsao/scc@npm:^1.1.0": + version: 1.1.0 + resolution: "@rtsao/scc@npm:1.1.0" + checksum: 10c0/b5bcfb0d87f7d1c1c7c0f7693f53b07866ed9fec4c34a97a8c948fb9a7c0082e416ce4d3b60beb4f5e167cbe04cdeefbf6771320f3ede059b9ce91188c409a5b + languageName: node + linkType: hard + "@rushstack/eslint-patch@npm:^1.3.3": version: 1.10.3 resolution: "@rushstack/eslint-patch@npm:1.10.3" @@ -4166,14 +4190,14 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^7.16.1": - version: 7.16.1 - resolution: "@typescript-eslint/eslint-plugin@npm:7.16.1" + version: 7.18.0 + resolution: "@typescript-eslint/eslint-plugin@npm:7.18.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:7.16.1" - "@typescript-eslint/type-utils": "npm:7.16.1" - "@typescript-eslint/utils": "npm:7.16.1" - "@typescript-eslint/visitor-keys": "npm:7.16.1" + "@typescript-eslint/scope-manager": "npm:7.18.0" + "@typescript-eslint/type-utils": "npm:7.18.0" + "@typescript-eslint/utils": "npm:7.18.0" + "@typescript-eslint/visitor-keys": "npm:7.18.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -4184,7 +4208,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/3d0d8fa7e00dff4deb70f41432030e4e0e0bc1e4415ae7be969b77bb216fd0797507ed852baaf6d12f6ae022f69ac6356201f6b4129ddfd57b232bfc6715ac8a + checksum: 10c0/2b37948fa1b0dab77138909dabef242a4d49ab93e4019d4ef930626f0a7d96b03e696cd027fa0087881c20e73be7be77c942606b4a76fa599e6b37f6985304c3 languageName: node linkType: hard @@ -4207,30 +4231,30 @@ __metadata: linkType: hard "@typescript-eslint/parser@npm:^7.16.1": - version: 7.16.1 - resolution: "@typescript-eslint/parser@npm:7.16.1" + version: 7.18.0 + resolution: "@typescript-eslint/parser@npm:7.18.0" dependencies: - "@typescript-eslint/scope-manager": "npm:7.16.1" - "@typescript-eslint/types": "npm:7.16.1" - "@typescript-eslint/typescript-estree": "npm:7.16.1" - "@typescript-eslint/visitor-keys": "npm:7.16.1" + "@typescript-eslint/scope-manager": "npm:7.18.0" + "@typescript-eslint/types": "npm:7.18.0" + "@typescript-eslint/typescript-estree": "npm:7.18.0" + "@typescript-eslint/visitor-keys": "npm:7.18.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/f0c731d9f22ccbcc2a15eb33376ae09cdcdcb4c69fcce425e8e7e5e3ccce51c4ee431d350109a02a09f40df81349c59eddd0264fe53a4194f326c0e0e2e3e83a + checksum: 10c0/370e73fca4278091bc1b657f85e7d74cd52b24257ea20c927a8e17546107ce04fbf313fec99aed0cc2a145ddbae1d3b12e9cc2c1320117636dc1281bcfd08059 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:7.16.1": - version: 7.16.1 - resolution: "@typescript-eslint/scope-manager@npm:7.16.1" +"@typescript-eslint/scope-manager@npm:7.18.0": + version: 7.18.0 + resolution: "@typescript-eslint/scope-manager@npm:7.18.0" dependencies: - "@typescript-eslint/types": "npm:7.16.1" - "@typescript-eslint/visitor-keys": "npm:7.16.1" - checksum: 10c0/5105edd927fd45097eb9c16f235ba48c2d9f2f3a3948fbdc4ffdc9a9fc5f130fa46c32d9188fe4bb303bd99508d7f0aad342c2ec0d9ad887aa1416dd54edeb66 + "@typescript-eslint/types": "npm:7.18.0" + "@typescript-eslint/visitor-keys": "npm:7.18.0" + checksum: 10c0/038cd58c2271de146b3a594afe2c99290034033326d57ff1f902976022c8b0138ffd3cb893ae439ae41003b5e4bcc00cabf6b244ce40e8668f9412cc96d97b8e languageName: node linkType: hard @@ -4244,12 +4268,12 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:7.16.1": - version: 7.16.1 - resolution: "@typescript-eslint/type-utils@npm:7.16.1" +"@typescript-eslint/type-utils@npm:7.18.0": + version: 7.18.0 + resolution: "@typescript-eslint/type-utils@npm:7.18.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:7.16.1" - "@typescript-eslint/utils": "npm:7.16.1" + "@typescript-eslint/typescript-estree": "npm:7.18.0" + "@typescript-eslint/utils": "npm:7.18.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependencies: @@ -4257,14 +4281,14 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/7551566185ca372dbc3d53b8ab047ea7e2c50b25d9a9293d5163498fb87c4b16a585d267a4a99df57d70326754acf168aad726ee5e8b9c0d4e59f1b8653d951d + checksum: 10c0/ad92a38007be620f3f7036f10e234abdc2fdc518787b5a7227e55fd12896dacf56e8b34578723fbf9bea8128df2510ba8eb6739439a3879eda9519476d5783fd languageName: node linkType: hard -"@typescript-eslint/types@npm:7.16.1": - version: 7.16.1 - resolution: "@typescript-eslint/types@npm:7.16.1" - checksum: 10c0/5ab7bfcac81adb01672057270d0273da98dcf50d2add5819b4787b5973f6624d11ad33d6fb495f80fe628fefa3a5ed319b433ed57e9121e444cfc002e1e48625 +"@typescript-eslint/types@npm:7.18.0": + version: 7.18.0 + resolution: "@typescript-eslint/types@npm:7.18.0" + checksum: 10c0/eb7371ac55ca77db8e59ba0310b41a74523f17e06f485a0ef819491bc3dd8909bb930120ff7d30aaf54e888167e0005aa1337011f3663dc90fb19203ce478054 languageName: node linkType: hard @@ -4275,12 +4299,12 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:7.16.1": - version: 7.16.1 - resolution: "@typescript-eslint/typescript-estree@npm:7.16.1" +"@typescript-eslint/typescript-estree@npm:7.18.0": + version: 7.18.0 + resolution: "@typescript-eslint/typescript-estree@npm:7.18.0" dependencies: - "@typescript-eslint/types": "npm:7.16.1" - "@typescript-eslint/visitor-keys": "npm:7.16.1" + "@typescript-eslint/types": "npm:7.18.0" + "@typescript-eslint/visitor-keys": "npm:7.18.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -4290,7 +4314,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/979269e9d42d75c0e49f47c7bb5e9554bd29041339c6fecfe5c76726699bce25132bef8b54210769e4f0abb858a278923340d3e4decc6551406e2c5ec065fe04 + checksum: 10c0/0c7f109a2e460ec8a1524339479cf78ff17814d23c83aa5112c77fb345e87b3642616291908dcddea1e671da63686403dfb712e4a4435104f92abdfddf9aba81 languageName: node linkType: hard @@ -4313,27 +4337,27 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:7.16.1": - version: 7.16.1 - resolution: "@typescript-eslint/utils@npm:7.16.1" +"@typescript-eslint/utils@npm:7.18.0": + version: 7.18.0 + resolution: "@typescript-eslint/utils@npm:7.18.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:7.16.1" - "@typescript-eslint/types": "npm:7.16.1" - "@typescript-eslint/typescript-estree": "npm:7.16.1" + "@typescript-eslint/scope-manager": "npm:7.18.0" + "@typescript-eslint/types": "npm:7.18.0" + "@typescript-eslint/typescript-estree": "npm:7.18.0" peerDependencies: eslint: ^8.56.0 - checksum: 10c0/22fbf17eec064d1e67f2a4bf512f62d5369a22fe11226f043cbeb0fe79cd18006b04f933e5025f4e5c2f82047248dac52cc97199e495ad17d564084210099d17 + checksum: 10c0/a25a6d50eb45c514469a01ff01f215115a4725fb18401055a847ddf20d1b681409c4027f349033a95c4ff7138d28c3b0a70253dfe8262eb732df4b87c547bd1e languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:7.16.1": - version: 7.16.1 - resolution: "@typescript-eslint/visitor-keys@npm:7.16.1" +"@typescript-eslint/visitor-keys@npm:7.18.0": + version: 7.18.0 + resolution: "@typescript-eslint/visitor-keys@npm:7.18.0" dependencies: - "@typescript-eslint/types": "npm:7.16.1" + "@typescript-eslint/types": "npm:7.18.0" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10c0/060bc6770ba3ea271c6a844501f4dfee1b8842a0c405e60d2a258466b1b4e66086234a3fddac8745bb1a39a89eab29afeaf16133ad925bd426ac8fdb13fb7f94 + checksum: 10c0/538b645f8ff1d9debf264865c69a317074eaff0255e63d7407046176b0f6a6beba34a6c51d511f12444bae12a98c69891eb6f403c9f54c6c2e2849d1c1cb73c0 languageName: node linkType: hard @@ -5197,7 +5221,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0": +"ajv@npm:^8.0.0, ajv@npm:^8.17.1": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: @@ -5472,6 +5496,20 @@ __metadata: languageName: node linkType: hard +"array.prototype.findlastindex@npm:^1.2.5": + version: 1.2.5 + resolution: "array.prototype.findlastindex@npm:1.2.5" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.23.2" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.0.0" + es-shim-unscopables: "npm:^1.0.2" + checksum: 10c0/962189487728b034f3134802b421b5f39e42ee2356d13b42d2ddb0e52057ffdcc170b9524867f4f0611a6f638f4c19b31e14606e8bcbda67799e26685b195aa3 + languageName: node + linkType: hard + "array.prototype.flat@npm:^1.3.1, array.prototype.flat@npm:^1.3.2": version: 1.3.2 resolution: "array.prototype.flat@npm:1.3.2" @@ -7306,6 +7344,16 @@ __metadata: languageName: node linkType: hard +"enhanced-resolve@npm:^5.15.0": + version: 5.18.1 + resolution: "enhanced-resolve@npm:5.18.1" + dependencies: + graceful-fs: "npm:^4.2.4" + tapable: "npm:^2.2.0" + checksum: 10c0/4cffd9b125225184e2abed9fdf0ed3dbd2224c873b165d0838fd066cde32e0918626cba2f1f4bf6860762f13a7e2364fd89a82b99566be2873d813573ac71846 + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.17.1": version: 5.17.1 resolution: "enhanced-resolve@npm:5.17.1" @@ -7747,7 +7795,7 @@ __metadata: languageName: node linkType: hard -"eslint-import-resolver-typescript@npm:^3.5.2, eslint-import-resolver-typescript@npm:^3.6.1": +"eslint-import-resolver-typescript@npm:^3.5.2": version: 3.6.1 resolution: "eslint-import-resolver-typescript@npm:3.6.1" dependencies: @@ -7765,6 +7813,42 @@ __metadata: languageName: node linkType: hard +"eslint-import-resolver-typescript@npm:^3.6.1": + version: 3.8.3 + resolution: "eslint-import-resolver-typescript@npm:3.8.3" + dependencies: + "@nolyfill/is-core-module": "npm:1.0.39" + debug: "npm:^4.3.7" + enhanced-resolve: "npm:^5.15.0" + get-tsconfig: "npm:^4.10.0" + is-bun-module: "npm:^1.0.2" + stable-hash: "npm:^0.0.4" + tinyglobby: "npm:^0.2.12" + peerDependencies: + eslint: "*" + eslint-plugin-import: "*" + eslint-plugin-import-x: "*" + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + checksum: 10c0/886ceeab4cad14958d7c7d3432ead2486374616c8ada7925ab96e55f919f2dbbbdbe7c3081d7d238231e84699849e82930417a66e05638bcc8202e1263edddeb + languageName: node + linkType: hard + +"eslint-module-utils@npm:^2.12.0": + version: 2.12.0 + resolution: "eslint-module-utils@npm:2.12.0" + dependencies: + debug: "npm:^3.2.7" + peerDependenciesMeta: + eslint: + optional: true + checksum: 10c0/4d8b46dcd525d71276f9be9ffac1d2be61c9d54cc53c992e6333cf957840dee09381842b1acbbb15fc6b255ebab99cd481c5007ab438e5455a14abe1a0468558 + languageName: node + linkType: hard + "eslint-module-utils@npm:^2.7.4, eslint-module-utils@npm:^2.8.0": version: 2.8.0 resolution: "eslint-module-utils@npm:2.8.0" @@ -7777,7 +7861,7 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-import@npm:^2.28.1, eslint-plugin-import@npm:^2.29.1": +"eslint-plugin-import@npm:^2.28.1": version: 2.29.1 resolution: "eslint-plugin-import@npm:2.29.1" dependencies: @@ -7804,6 +7888,35 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-import@npm:^2.29.1": + version: 2.31.0 + resolution: "eslint-plugin-import@npm:2.31.0" + dependencies: + "@rtsao/scc": "npm:^1.1.0" + array-includes: "npm:^3.1.8" + array.prototype.findlastindex: "npm:^1.2.5" + array.prototype.flat: "npm:^1.3.2" + array.prototype.flatmap: "npm:^1.3.2" + debug: "npm:^3.2.7" + doctrine: "npm:^2.1.0" + eslint-import-resolver-node: "npm:^0.3.9" + eslint-module-utils: "npm:^2.12.0" + hasown: "npm:^2.0.2" + is-core-module: "npm:^2.15.1" + is-glob: "npm:^4.0.3" + minimatch: "npm:^3.1.2" + object.fromentries: "npm:^2.0.8" + object.groupby: "npm:^1.0.3" + object.values: "npm:^1.2.0" + semver: "npm:^6.3.1" + string.prototype.trimend: "npm:^1.0.8" + tsconfig-paths: "npm:^3.15.0" + peerDependencies: + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + checksum: 10c0/e21d116ddd1900e091ad120b3eb68c5dd5437fe2c930f1211781cd38b246f090a6b74d5f3800b8255a0ed29782591521ad44eb21c5534960a8f1fb4040fd913a + languageName: node + linkType: hard + "eslint-plugin-jsx-a11y@npm:^6.7.1": version: 6.9.0 resolution: "eslint-plugin-jsx-a11y@npm:6.9.0" @@ -8241,6 +8354,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.4.3": + version: 6.4.3 + resolution: "fdir@npm:6.4.3" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/d13c10120e9625adf21d8d80481586200759928c19405a816b77dd28eaeb80e7c59c5def3e2941508045eb06d34eb47fad865ccc8bf98e6ab988bb0ed160fb6f + languageName: node + linkType: hard + "figures@npm:3.2.0, figures@npm:^3.0.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -8306,6 +8431,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" @@ -8655,6 +8791,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.10.0": + version: 4.10.0 + resolution: "get-tsconfig@npm:4.10.0" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/c9b5572c5118923c491c04285c73bd55b19e214992af957c502a3be0fc0043bb421386ffd45ca3433c0a7fba81221ca300479e8393960acf15d0ed4563f38a86 + languageName: node + linkType: hard + "get-tsconfig@npm:^4.5.0": version: 4.7.0 resolution: "get-tsconfig@npm:4.7.0" @@ -9553,6 +9698,15 @@ __metadata: languageName: node linkType: hard +"is-bun-module@npm:^1.0.2": + version: 1.3.0 + resolution: "is-bun-module@npm:1.3.0" + dependencies: + semver: "npm:^7.6.3" + checksum: 10c0/2966744188fcd28e0123c52158c7073973f88babfa9ab04e2846ec5862d6b0f8f398df6413429d930f7c5ee6111ce2cbfb3eb8652d9ec42d4a37dc5089a866fb + languageName: node + linkType: hard + "is-bundling-for-browser-or-node@npm:^1.1.1": version: 1.1.1 resolution: "is-bundling-for-browser-or-node@npm:1.1.1" @@ -9596,6 +9750,15 @@ __metadata: languageName: node linkType: hard +"is-core-module@npm:^2.15.1": + version: 2.16.1 + resolution: "is-core-module@npm:2.16.1" + dependencies: + hasown: "npm:^2.0.2" + checksum: 10c0/898443c14780a577e807618aaae2b6f745c8538eca5c7bc11388a3f2dc6de82b9902bcc7eb74f07be672b11bbe82dd6a6edded44a00cb3d8f933d0459905eedd + languageName: node + linkType: hard + "is-data-view@npm:^1.0.1": version: 1.0.1 resolution: "is-data-view@npm:1.0.1" @@ -10683,6 +10846,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" @@ -12174,6 +12346,17 @@ __metadata: languageName: node linkType: hard +"object.groupby@npm:^1.0.3": + version: 1.0.3 + resolution: "object.groupby@npm:1.0.3" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.23.2" + checksum: 10c0/60d0455c85c736fbfeda0217d1a77525956f76f7b2495edeca9e9bbf8168a45783199e77b894d30638837c654d0cc410e0e02cbfcf445bc8de71c3da1ede6a9c + languageName: node + linkType: hard + "object.values@npm:^1.1.6, object.values@npm:^1.2.0": version: 1.2.0 resolution: "object.values@npm:1.2.0" @@ -12376,6 +12559,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" @@ -12412,6 +12604,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" @@ -12598,6 +12799,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" @@ -12718,6 +12926,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.2": + version: 4.0.2 + resolution: "picomatch@npm:4.0.2" + checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc + languageName: node + linkType: hard + "pify@npm:5.0.0": version: 5.0.0 resolution: "pify@npm:5.0.0" @@ -14484,6 +14699,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.3": + version: 7.7.1 + resolution: "semver@npm:7.7.1" + bin: + semver: bin/semver.js + checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958 + languageName: node + linkType: hard + "serialize-javascript@npm:^6.0.1": version: 6.0.1 resolution: "serialize-javascript@npm:6.0.1" @@ -14831,6 +15055,13 @@ __metadata: languageName: node linkType: hard +"stable-hash@npm:^0.0.4": + version: 0.0.4 + resolution: "stable-hash@npm:0.0.4" + checksum: 10c0/53d010d2a1b014fb60d398c095f43912c353b7b44774e55222bb26fd428bc75b73d7bdfcae509ce927c23ca9c5aff2dc1bc82f191d30e57a879550bc2952bdb0 + languageName: node + linkType: hard + "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -15424,6 +15655,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.12": + version: 0.2.12 + resolution: "tinyglobby@npm:0.2.12" + dependencies: + fdir: "npm:^6.4.3" + picomatch: "npm:^4.0.2" + checksum: 10c0/7c9be4fd3625630e262dcb19015302aad3b4ba7fc620f269313e688f2161ea8724d6cb4444baab5ef2826eb6bed72647b169a33ec8eea37501832a2526ff540f + languageName: node + linkType: hard + "tinypool@npm:^0.8.3": version: 0.8.4 resolution: "tinypool@npm:0.8.4" @@ -15552,7 +15793,7 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^1.0.1, ts-api-utils@npm:^1.3.0": +"ts-api-utils@npm:^1.0.1": version: 1.3.0 resolution: "ts-api-utils@npm:1.3.0" peerDependencies: @@ -15561,6 +15802,15 @@ __metadata: languageName: node linkType: hard +"ts-api-utils@npm:^1.3.0": + version: 1.4.3 + resolution: "ts-api-utils@npm:1.4.3" + peerDependencies: + typescript: ">=4.2.0" + checksum: 10c0/e65dc6e7e8141140c23e1dc94984bf995d4f6801919c71d6dc27cf0cd51b100a91ffcfe5217626193e5bea9d46831e8586febdc7e172df3f1091a7384299e23a + languageName: node + linkType: hard + "ts-interface-checker@npm:^0.1.9": version: 0.1.13 resolution: "ts-interface-checker@npm:0.1.13" @@ -16054,6 +16304,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" From 46acd23d8ecda9da2ac9d665706d126d8c0ac1c8 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 24 Feb 2025 15:57:01 +0100 Subject: [PATCH 08/60] feat: new command and cleaned up existing commands --- packages/cli/bucket.config.json | 6 +- packages/cli/commands/apps.ts | 14 +-- packages/cli/commands/auth.ts | 17 ++- packages/cli/commands/features.ts | 170 +++++++++++++++++++----------- packages/cli/commands/init.ts | 48 +++++---- packages/cli/commands/new.ts | 62 +++++++++++ packages/cli/index.ts | 27 ++--- packages/cli/package.json | 5 +- packages/cli/schema.json | 2 +- packages/cli/services/features.ts | 18 +--- packages/cli/utils/auth.ts | 79 ++++++++------ packages/cli/utils/config.ts | 140 +++++++++++++++--------- packages/cli/utils/constants.ts | 3 + packages/cli/utils/error.ts | 17 ++- packages/cli/utils/gen.ts | 112 ++++++++++++++++---- packages/cli/utils/options.ts | 55 ++++++++-- yarn.lock | 59 ++--------- 17 files changed, 522 insertions(+), 312 deletions(-) create mode 100644 packages/cli/commands/new.ts diff --git a/packages/cli/bucket.config.json b/packages/cli/bucket.config.json index 53fedd24..96bbdbcc 100644 --- a/packages/cli/bucket.config.json +++ b/packages/cli/bucket.config.json @@ -1,8 +1,6 @@ { "$schema": "https://unpkg.com/@bucketco/cli@0.0.1/schema.json", + "appId": "apbW6RrMgL2IhC", "baseUrl": "https://staging.app.bucket.co", - "apiUrl": "https://staging.app.bucket.co/api", - "appId": "aptC9gNxk8u7IE", - "typesPath": "gen/features.ts", - "keyFormat": "custom" + "keyFormat": "snakeCaseUppers" } diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index 394b8fb2..2fffd64e 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -1,20 +1,20 @@ -import chalk from "chalk"; -import { Command } from "commander"; +import { Command, program } from "commander"; import ora from "ora"; import { listApps } from "../services/bootstrap.js"; import { handleError } from "../utils/error.js"; +import chalk from "chalk"; export const listAppsAction = async () => { - const spinner = ora("Loading apps...").start(); + const { baseUrl } = program.opts(); + const spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); try { const apps = await listApps(); - spinner.succeed(); - console.log(chalk.green("Available apps:")); + spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); console.table(apps); } catch (error) { - spinner.fail(); - handleError(error, "Failed to list apps:"); + spinner.fail("Failed to list apps"); + handleError(error, "Apps List"); } }; diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index cb9d3e1b..5d9813c8 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -1,19 +1,19 @@ -import chalk from "chalk"; -import { Command } from "commander"; +import { Command, program } from "commander"; import ora from "ora"; +import chalk from "chalk"; import { authenticateUser, storeToken } from "../utils/auth.js"; import { handleError } from "../utils/error.js"; export const loginAction = async () => { - const spinner = ora("Logging in...").start(); + const { baseUrl } = program.opts(); + const spinner = ora(`Logging in to ${chalk.cyan(baseUrl)}...`).start(); try { await authenticateUser(); - spinner.succeed(); - console.log(chalk.green("Logged in successfully!")); + spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully! 🎉`); } catch (error) { spinner.fail(); - handleError(error, "Authentication failed:"); + handleError(error, "Login"); } }; @@ -21,11 +21,10 @@ export const logoutAction = async () => { const spinner = ora("Logging out...").start(); try { await storeToken(""); - spinner.succeed(); - console.log(chalk.green("Logged out successfully!")); + spinner.succeed("Logged out successfully! 👋"); } catch (error) { spinner.fail(); - handleError(error, "Logout failed:"); + handleError(error, "Logout"); } }; diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 3a500dbf..98afbca5 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -1,76 +1,111 @@ import chalk from "chalk"; -import { Command } from "commander"; -import { outputFile } from "fs-extra/esm"; +import { Command, program } from "commander"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, isAbsolute } from "node:path"; import ora from "ora"; -import { - createFeature, - genFeatureTypes, - listFeatures, -} from "../services/features.js"; -import { getConfig } from "../utils/config.js"; -import { - CONFIG_FILE_NAME, - DEFAULT_TYPES_PATH as DEFAULT_TYPES_PATH, -} from "../utils/constants.js"; +import { createFeature, listFeatures } from "../services/features.js"; +import { getConfig, getProjectPath } from "../utils/config.js"; import { handleError } from "../utils/error.js"; -import { appIdOption } from "../utils/options.js"; +import { input } from "@inquirer/prompts"; +import { genDTS, genFeatureKey, KeyFormatPatterns } from "../utils/gen.js"; +import { options } from "../utils/options.js"; type AppIdArgs = { appId: string; }; -export const listFeaturesAction = async ({ appId }: AppIdArgs) => { - const spinner = ora("Loading features...").start(); - try { - const features = await listFeatures(appId); - spinner.succeed(); - console.log(chalk.green(`Features in ${appId}:`)); - console.table(features); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to list features:"); - } +type CreateFeatureArgs = AppIdArgs & { + key?: string; }; type GenerateTypesArgs = AppIdArgs & { out: string; }; -export const generateTypesAction = async ({ - appId, - out, -}: GenerateTypesArgs) => { - const spinner = ora("Generating feature types...").start(); +export const createFeatureAction = async ( + name: string | undefined, + { appId, key }: CreateFeatureArgs, +) => { + const { baseUrl } = program.opts(); + const spinner = ora( + `Loading features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); try { - const types = await genFeatureTypes(appId); - await outputFile(out, types); - spinner.succeed(); - console.log(chalk.green(`Generated features for ${appId}.`)); + const features = await listFeatures(appId); + const existingKeys = features.map((f) => f.key); + spinner.succeed( + `Loaded features app ${chalk.cyan(appId)} at from ${chalk.cyan(baseUrl)}`, + ); + + if (!name) { + name = await input({ + message: "New feature name:", + validate: (input) => input.length > 0 || "Name is required", + }); + } + + if (!key) { + const keyFormat = getConfig("keyFormat") ?? "custom"; + key = await input({ + message: "New feature key:", + default: genFeatureKey(name, keyFormat, existingKeys), + validate: KeyFormatPatterns[keyFormat].validate, + }); + } + + spinner.start("Creating feature..."); + const feature = await createFeature(appId, name, key); + spinner.succeed( + `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}. 🎉`, + ); } catch (error) { spinner.fail(); - handleError(error, "Failed to generate feature types:"); + handleError(error, "Features Create"); } }; -type CreateFeatureArgs = AppIdArgs & { - key?: string; +export const listFeaturesAction = async ({ appId }: AppIdArgs) => { + const { baseUrl } = program.opts(); + const spinner = ora( + `Loading features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); + try { + const features = await listFeatures(appId); + spinner.succeed( + `Loaded features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, + ); + console.table(features); + } catch (error) { + spinner.fail(); + handleError(error, "Features List"); + } }; -export const createFeatureAction = async ( - name: string, - { appId, key }: CreateFeatureArgs, -) => { - const spinner = ora("Creating feature...").start(); +export const generateTypesAction = async ({ + appId, + out, +}: GenerateTypesArgs) => { + const { baseUrl } = program.opts(); + let spinner = ora( + `Loading features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); try { - const feature = await createFeature(appId, name, key); - spinner.succeed(); - console.log( - chalk.green(`Created feature ${feature.name} with key ${feature.key}.`), + const feature = await listFeatures(appId); + spinner.succeed( + `Loaded features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, ); + + spinner = ora("Generating feature types...").start(); + const types = genDTS(feature.map(({ key }) => key)); + const outPath = isAbsolute(out) ? out : join(getProjectPath(), out); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, types); + spinner.succeed("Generated feature types successfully"); + console.log(chalk.green(`Generated types for ${appId}.`)); } catch (error) { spinner.fail(); - handleError(error, "Failed to create feature:"); + handleError(error, "Features Types"); } }; @@ -79,33 +114,42 @@ export function registerFeatureCommands(program: Command) { "Manage features", ); + featuresCommand + .command("create") + .description("Create a new feature") + .requiredOption( + options.appId.flags, + options.appId.description, + getConfig(options.appId.configKey), + ) + .option(options.featureKey.flags, options.featureKey.description) + .argument(options.featureName.flags, options.featureName.description) + .action(createFeatureAction); + featuresCommand .command("list") .description("List all features") - .addOption(appIdOption) + .requiredOption( + options.appId.flags, + options.appId.description, + getConfig(options.appId.configKey), + ) .action(listFeaturesAction); featuresCommand .command("types") .description("Generate feature types") - .addOption(appIdOption) - .option( - "-o, --out ", - `Generate types for features at the output path. Falls back to typePath stored in ${CONFIG_FILE_NAME} or ${DEFAULT_TYPES_PATH}.`, - getConfig("typesPath") ?? DEFAULT_TYPES_PATH, + .requiredOption( + options.appId.flags, + options.appId.description, + getConfig(options.appId.configKey), ) - .action(generateTypesAction); - - featuresCommand - .command("create") - .description("Create a new feature") - .argument("", "Name of the feature") - .addOption(appIdOption) - .option( - "-k, --key ", - `Create a feature in the app with the given feature key. Falls back to a slug of the feature name.`, + .requiredOption( + options.typesOut.flags, + options.typesOut.description, + getConfig(options.typesOut.configKey) ?? options.typesOut.fallback, ) - .action(createFeatureAction); + .action(generateTypesAction); program.addCommand(featuresCommand); } diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index 1763a572..6688e51c 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -1,27 +1,40 @@ import { input, select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command, program } from "commander"; -import ora from "ora"; +import ora, { Ora } from "ora"; import { listApps } from "../services/bootstrap.js"; -import { createConfigFile } from "../utils/config.js"; +import { createConfig, getConfigPath } from "../utils/config.js"; -import { DEFAULT_TYPES_PATH } from "../utils/constants.js"; +import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js"; import { handleError } from "../utils/error.js"; +import { options } from "../utils/options.js"; type InitArgs = { force?: boolean; }; export const initAction = async (args: InitArgs) => { - console.log(chalk.magenta("\nWelcome to Bucket! 🪣\n")); - const { baseUrl, apiUrl } = program.opts(); + let spinner: Ora | undefined; try { - // Check if already authenticated - let spinner = ora(`Authenticating with ${baseUrl}...`).start(); + // Check if already initialized + const configPath = getConfigPath(); + if (configPath) { + if (!args.force) { + throw new Error( + "Bucket is already initialized. Use --force to overwrite.", + ); + } + } + + console.log(chalkBrand("\nWelcome to Bucket! 🪣\n")); + const { baseUrl, apiUrl } = program.opts(); + + // Load apps + spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); const apps = await listApps(); - spinner.succeed("Authenticated"); + spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); let appId: string | undefined; @@ -57,7 +70,7 @@ export const initAction = async (args: InitArgs) => { // Create config file spinner = ora("Creating configuration...").start(); - await createConfigFile( + await createConfig( { baseUrl, apiUrl, @@ -67,16 +80,10 @@ export const initAction = async (args: InitArgs) => { }, args.force, ); - spinner.succeed("Configuration created"); - - console.log(chalk.green("\nBucket initialized successfully! 🎉")); - console.log( - chalk.gray( - "\nNext steps:\n1. Run 'bucket features sync' to sync your feature flags\n2. Import the generated types in your code", - ), - ); + spinner.succeed(`Configuration created at ${getConfigPath()}! 🎉`); } catch (error) { - handleError(error, "Initialization failed:"); + spinner?.fail(); + handleError(error, "Initialization"); } }; @@ -84,9 +91,6 @@ export function registerInitCommand(program: Command) { program .command("init") .description("Initialize a new Bucket configuration") - .option( - "-f, --force", - "Force initialization overwriting existing configuration", - ) + .option(options.initOverride.flags, options.initOverride.description) .action(initAction); } diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts new file mode 100644 index 00000000..0e8ffa2f --- /dev/null +++ b/packages/cli/commands/new.ts @@ -0,0 +1,62 @@ +import { Command } from "commander"; +import { findUp } from "find-up"; +import { CONFIG_FILE_NAME } from "../utils/constants.js"; +import { initAction } from "./init.js"; +import { createFeatureAction, generateTypesAction } from "./features.js"; +import { handleError } from "../utils/error.js"; +import { getConfig } from "../utils/config.js"; +import { options } from "../utils/options.js"; + +type NewArgs = { + appId?: string; + out: string; + key?: string; +}; + +export const newAction = async ( + name: string | undefined, + { appId, out, key }: NewArgs, +) => { + try { + if (!(await findUp(CONFIG_FILE_NAME))) { + await initAction({}); + } + appId = appId ?? getConfig("appId"); + if (!appId) { + throw new Error( + "App ID is required. Please provide it with --appId or in the config file.", + ); + } + await createFeatureAction(name, { + appId, + key, + }); + await generateTypesAction({ + appId, + out, + }); + } catch (error) { + handleError(error, "New"); + } +}; + +export function registerNewCommand(program: Command) { + program + .command("new") + .description( + "Initialize the Bucket CLI, authenticates, and creates a new feature", + ) + .option( + options.appId.flags, + options.appId.description, + getConfig(options.appId.configKey), + ) + .option( + options.typesOut.flags, + options.typesOut.description, + getConfig(options.typesOut.configKey) ?? options.typesOut.fallback, + ) + .option(options.featureKey.flags, options.featureKey.description) + .argument(options.featureName.flags, options.featureName.description) + .action(newAction); +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts index c64ef661..e6273a8f 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -4,26 +4,27 @@ import { program } from "commander"; import { registerAuthCommands } from "./commands/auth.js"; import { registerFeatureCommands } from "./commands/features.js"; import { registerInitCommand } from "./commands/init.js"; -import { getConfig, readConfigFile } from "./utils/config.js"; +import { getConfig, getConfigPath, loadConfig } from "./utils/config.js"; import { registerAppCommands } from "./commands/apps.js"; import chalk from "chalk"; -import { DEFAULT_API_URL, DEFAULT_BASE_URL } from "./utils/constants.js"; +import { registerNewCommand } from "./commands/new.js"; +import { options } from "./utils/options.js"; async function main() { - // Read the config file - const config = await readConfigFile(); + // Must load config before anything else + await loadConfig(); // Global options - program.option("--debug", "Enable debug mode", false); + program.option(options.debug.flags, options.debug.description, false); program.requiredOption( - "--base-url [url]", - "Specify the Bucket base url", - getConfig("baseUrl") ?? DEFAULT_BASE_URL, + options.baseUrl.flags, + options.baseUrl.description, + getConfig(options.baseUrl.configKey) ?? options.baseUrl.fallback, ); program.option( - "--api-url [url]", - "Specify the Bucket API url", - getConfig("apiUrl") ?? DEFAULT_API_URL, + options.apiUrl.flags, + options.apiUrl.description, + getConfig(options.apiUrl.configKey), ); // Pre-action hook @@ -31,11 +32,13 @@ async function main() { const { debug } = program.opts(); if (debug) { console.debug(chalk.cyan("\nDebug mode enabled")); - console.table(config); + console.debug("Reading config from", chalk.green(getConfigPath())); + console.table(getConfig()); } }); // Main program + registerNewCommand(program); registerInitCommand(program); registerAuthCommands(program); registerAppCommands(program); diff --git a/packages/cli/package.json b/packages/cli/package.json index 9fca913f..bbfff8d5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,13 +18,12 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", - "@sindresorhus/slugify": "^2.2.1", "ajv": "^8.17.1", "axios": "^1.7.5", "chalk": "^5.3.0", + "change-case": "^5.4.4", "commander": "^12.1.0", "find-up": "^7.0.0", - "fs-extra": "^11.2.0", "json5": "^2.2.3", "open": "^10.1.0", "ora": "^8.1.0" @@ -32,9 +31,7 @@ "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" } } diff --git a/packages/cli/schema.json b/packages/cli/schema.json index f45c0a6d..e8b54954 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -28,5 +28,5 @@ ] } }, - "required": ["appId", "typesPath", "keyFormat"] + "required": ["appId"] } diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index c897ad8c..4ddb92ff 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -1,5 +1,4 @@ import { authRequest } from "../utils/auth.js"; -import { genDTS, genFeatureKey } from "../utils/gen.js"; type Feature = { name: string; @@ -21,31 +20,18 @@ export async function listFeatures(appId: string) { })); } -export async function genFeatureTypes(appId: string) { - const response = await listFeatures(appId); - return genDTS(response.map(({ key }) => key)); -} - type FeatureResponse = { feature: Feature; }; -export async function createFeature( - appId: string, - name: string, - key: string | undefined, -) { - const features = await listFeatures(appId); +export async function createFeature(appId: string, name: string, key: string) { const response = await authRequest( `/apps/${appId}/features`, { method: "POST", data: { name, - key: genFeatureKey( - key ?? name, - features.map(({ key }) => key), - ), + key, source: "event", }, }, diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 0fb10072..a93d3981 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -16,19 +16,6 @@ export async function storeToken(newToken: string) { await writeFile(AUTH_FILE, newToken); } -// function readBody(req: IncomingMessage) { -// return new Promise((resolve) => { -// let bodyChunks: any = []; - -// req.on("data", (chunk) => { -// bodyChunks.push(chunk); -// }); -// req.on("end", () => { -// resolve(Buffer.concat(bodyChunks).toString()); -// }); -// }); -// } - function corsHeaders(origin: string): Record { return { "Access-Control-Allow-Origin": origin, @@ -37,21 +24,24 @@ function corsHeaders(origin: string): Record { }; } -/** - * @return {Promise} - */ export async function authenticateUser() { return new Promise((resolve, reject) => { const { baseUrl } = program.opts(); + let isResolved = false; const server = http.createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const headers = corsHeaders(baseUrl); + // Ensure we don't process requests after resolution + if (isResolved) { + res.writeHead(503, headers).end(); + return; + } + if (url.pathname !== "/cli-login") { res.writeHead(404).end("Invalid path"); - server.close(); - reject(new Error("Could not authenticate: Invalid path")); + cleanupAndReject(new Error("Could not authenticate: Invalid path")); return; } @@ -64,30 +54,53 @@ export async function authenticateUser() { if (!req.headers.authorization?.startsWith("Bearer ")) { res.writeHead(400, headers).end("Could not authenticate"); - server.close(); - reject(new Error("Could not authenticate")); + cleanupAndReject(new Error("Could not authenticate")); return; } const token = req.headers.authorization.slice("Bearer ".length); - - //const body = JSON.parse(await readBody(req)); - headers["Content-Type"] = "application/json"; - res.writeHead(200, headers); res.end(JSON.stringify({ result: "success" })); - server.close(); - await storeToken(token); - resolve(token); + try { + await storeToken(token); + cleanupAndResolve(token); + } catch (error) { + cleanupAndReject( + error instanceof Error ? error : new Error("Failed to store token"), + ); + } }); const timeout = setTimeout(() => { - server.close(); - reject(new Error("Authentication timed out after 30 seconds")); + cleanupAndReject(new Error("Authentication timed out after 30 seconds")); }, 30000); + function cleanupAndResolve(token: string) { + if (isResolved) return; + isResolved = true; + cleanup(); + resolve(token); + } + + function cleanupAndReject(error: Error) { + if (isResolved) return; + isResolved = true; + cleanup(); + reject(error); + } + + function cleanup() { + clearTimeout(timeout); + server.close(); + // Force-close any remaining connections + server.getConnections((err, count) => { + if (err || count === 0) return; + server.closeAllConnections(); + }); + } + server.listen(); const address = server.address(); if (address && typeof address === "object") { @@ -96,11 +109,6 @@ export async function authenticateUser() { newInstance: true, }); } - - // Cleanup timeout when server closes - server.on("close", () => { - clearTimeout(timeout); - }); }); } @@ -110,7 +118,8 @@ export async function authRequest>( retryCount = 0, ): Promise { const token = await getToken(); - const { apiUrl } = program.opts(); + let { baseUrl, apiUrl } = program.opts(); + apiUrl = apiUrl ?? `${baseUrl}/api`; try { const response = await axios({ ...options, diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 150e6e9e..f25da7cf 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -1,15 +1,21 @@ -import { readFile, writeFile, mkdir, access } from "fs/promises"; -import { dirname } from "path"; +import { readFile, writeFile } from "fs/promises"; import { Ajv } from "ajv"; import JSON5 from "json5"; import { createRequire } from "module"; +import { + CONFIG_FILE_NAME, + DEFAULT_API_URL, + DEFAULT_BASE_URL, + DEFAULT_TYPES_PATH, + SCHEMA_URL, +} from "./constants.js"; +import { findUp } from "find-up"; +import { handleError } from "./error.js"; +import { dirname, join } from "path"; // https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 const schema = createRequire(import.meta.url)("../../schema.json"); -import { CONFIG_FILE_NAME, SCHEMA_URL } from "./constants.js"; -import { findUp } from "find-up"; - const ajv = new Ajv(); const validateConfig = ajv.compile(schema); @@ -28,9 +34,15 @@ export type KeyFormat = (typeof keyFormats)[number]; class ConfigValidationError extends Error { constructor(errors: typeof validateConfig.errors) { const messages = errors - ?.map((e) => `${e.instancePath} ${e.message}`) - .join("; "); - super(`Invalid config: ${messages}`); + ?.map((e) => { + const path = e.instancePath || "config"; + const value = e.params?.allowedValues + ? `: ${e.params.allowedValues.join(", ")}` + : ""; + return `${path}: ${e.message}${value}`; + }) + .join("\n"); + super(messages); this.name = "ConfigValidationError"; } } @@ -40,50 +52,56 @@ type Config = { baseUrl?: string; apiUrl?: string; appId: string; - typesPath: string; - keyFormat: KeyFormat; + typesPath?: string; + keyFormat?: KeyFormat; }; -let config: Config = { - appId: "", - typesPath: "", - keyFormat: "custom", -}; +let config: Config | undefined; +let configPath: string | undefined; +let projectPath: string | undefined; /** * Instantly return a specified key's value or the entire config object. */ -export function getConfig(): Config; -export function getConfig(key: keyof Config): string | undefined; -export function getConfig(key?: keyof Config) { - return key ? config[key] : config; +export function getConfig(): Config | undefined; +export function getConfig( + key: K, +): Config[K] | undefined; +export function getConfig(key?: K) { + return key ? config?.[key] : config; +} + +/** + * Return the path to the config file. + */ +export function getConfigPath() { + return configPath; } /** - * Read the config file and return either a specified key's value or the entire config object. + * Return the path to the project root. */ -export async function readConfigFile(): Promise; -export async function readConfigFile( - key: keyof Config, -): Promise; -export async function readConfigFile(key?: keyof Config) { +export function getProjectPath() { + return projectPath ?? process.cwd(); +} + +/** + * Load the configuration file. + */ +export async function loadConfig() { try { - const configPath = await findUp(CONFIG_FILE_NAME); - if (!configPath) { - return {}; - } + const packageJSONPath = await findUp("package.json"); + configPath = await findUp(CONFIG_FILE_NAME); + projectPath = dirname(configPath ?? packageJSONPath ?? process.cwd()); + if (!configPath) return; const content = await readFile(configPath, "utf-8"); const parsed = JSON5.parse(content); if (!validateConfig(parsed)) { - throw new ConfigValidationError(validateConfig.errors); + handleError(new ConfigValidationError(validateConfig.errors), "Config"); } config = parsed; - return key ? config[key] : config; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return {}; - } - throw error; + // No config file found } } @@ -92,25 +110,49 @@ export async function readConfigFile(key?: keyof Config) { * @param newConfig The configuration object to write * @param overwrite If true, overwrites existing config file. Defaults to false */ -export async function createConfigFile(newConfig: Config, overwrite = false) { +const getDefaultConfig = (): Partial => ({ + baseUrl: DEFAULT_BASE_URL, + apiUrl: DEFAULT_API_URL, + typesPath: DEFAULT_TYPES_PATH, + keyFormat: "custom", +}); + +export async function createConfig(newConfig: Config, overwrite = false) { if (!validateConfig(newConfig)) { - throw new ConfigValidationError(validateConfig.errors); + handleError(new ConfigValidationError(validateConfig.errors), "Config"); } - newConfig = { $schema: SCHEMA_URL, ...newConfig }; - try { - await access(CONFIG_FILE_NAME); - if (!overwrite) { - throw new Error("Config file already exists"); + + const defaults = getDefaultConfig(); + const configWithoutDefaults: Config = { + $schema: SCHEMA_URL, + appId: newConfig.appId, + }; + + // Only include non-default values + Object.entries(newConfig).forEach(([key, value]) => { + if (key === "$schema") return; // Using our own schema URL + if (key === "appId") return; // Already included + if (value !== defaults[key as keyof typeof defaults]) { + (configWithoutDefaults as any)[key] = value; } - await writeFile(CONFIG_FILE_NAME, JSON.stringify(newConfig, null, 2)); - config = newConfig; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - await mkdir(dirname(CONFIG_FILE_NAME), { recursive: true }); - await writeFile(CONFIG_FILE_NAME, JSON.stringify(newConfig, null, 2)); + }); + + const configJSON = JSON.stringify(configWithoutDefaults, null, 2); + + try { + if (configPath) { + if (!overwrite) { + throw new Error("Config file already exists"); + } + await writeFile(configPath, configJSON); + config = newConfig; + } else { + // Write to the nearest package.json directory + const configPath = join(getProjectPath(), CONFIG_FILE_NAME); + await writeFile(configPath, configJSON); config = newConfig; - return; } + } catch (error) { throw error; } } diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 0f177b3e..95c357ec 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,5 +1,6 @@ import path from "path"; import { createRequire } from "module"; +import chalk from "chalk"; // https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 const packageJson = createRequire(import.meta.url)("../../package.json"); @@ -18,3 +19,5 @@ export const DEFAULT_TYPES_PATH = path.join("gen", "features.ts"); export const loginUrl = (baseUrl: string, localPort: number) => `${baseUrl}/login?redirect_url=` + encodeURIComponent("/cli-login?port=" + localPort); + +export const chalkBrand = chalk.hex("#847CFB"); diff --git a/packages/cli/utils/error.ts b/packages/cli/utils/error.ts index 7867e562..6d457976 100644 --- a/packages/cli/utils/error.ts +++ b/packages/cli/utils/error.ts @@ -1,15 +1,12 @@ import { AxiosError } from "axios"; import chalk from "chalk"; -export function handleError(error: unknown, message?: string | null) { +export function handleError(error: unknown, tag: string) { + tag = chalk.bold(`[${tag}] error:`); + 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, - ), - ); + console.error(chalk.red(tag, data.error?.message ?? data.error?.code)); if (data.validationErrors) { console.table( data.validationErrors.map( @@ -21,11 +18,11 @@ export function handleError(error: unknown, message?: string | null) { ); } } else if (error instanceof Error) { - console.error(message, error.message); + console.error(chalk.red(tag, error.message)); } else if (typeof error === "string") { - console.error(message, error); + console.error(chalk.red(tag, error)); } else { - console.error(message ?? "An unknown error occurred:", error); + console.error(chalk.red(tag ?? "An unknown error occurred:", error)); } process.exit(1); } diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index 47d74b94..780e3e46 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -1,31 +1,101 @@ -import slugify from "@sindresorhus/slugify"; +import { camelCase, kebabCase, pascalCase, snakeCase } from "change-case"; + +// Keep in sync with Bucket main repo +export const KeyFormats = [ + "custom", + "pascalCase", + "camelCase", + "snakeCaseUpper", + "snakeCaseLower", + "kebabCaseUpper", + "kebabCaseLower", +] as const; + +export type KeyFormat = (typeof KeyFormats)[number]; + +type KeyFormatPattern = { + transform: (key: string) => string; + validate: (key: string) => true | string; +}; + +export const KeyFormatPatterns: Record = { + custom: { + transform: (key) => key?.trim(), + validate: (key) => + /^[\p{L}\p{N}\p{P}\p{S}\p{Z}]+$/u.test(key) || + "Key must contain only letters, numbers, punctuation, symbols, or spaces", + }, + pascalCase: { + transform: (key) => pascalCase(key), + validate: (key) => + /^[\p{Lu}][\p{L}\p{N}]*$/u.test(key) || + "Key must start with uppercase letter and contain only letters and numbers", + }, + camelCase: { + transform: (key) => camelCase(key), + validate: (key) => + /^[\p{Ll}][\p{L}\p{N}]*$/u.test(key) || + "Key must start with lowercase letter and contain only letters and numbers", + }, + snakeCaseUpper: { + transform: (key) => snakeCase(key).toUpperCase(), + validate: (key) => + /^[\p{Lu}][\p{Lu}\p{N}]*(?:_[\p{Lu}\p{N}]+)*$/u.test(key) || + "Key must be uppercase with words separated by underscores", + }, + snakeCaseLower: { + transform: (key) => snakeCase(key).toLowerCase(), + validate: (key) => + /^[\p{Ll}][\p{Ll}\p{N}]*(?:_[\p{Ll}\p{N}]+)*$/u.test(key) || + "Key must be lowercase with words separated by underscores", + }, + kebabCaseUpper: { + transform: (key) => kebabCase(key).toUpperCase(), + validate: (key) => + /^[\p{Lu}][\p{Lu}\p{N}]*(?:-[\p{Lu}\p{N}]+)*$/u.test(key) || + "Key must be uppercase with words separated by hyphens", + }, + kebabCaseLower: { + transform: (key) => kebabCase(key).toLowerCase(), + validate: (key) => + /^[\p{Ll}][\p{Ll}\p{N}]*(?:-[\p{Ll}\p{N}]+)*$/u.test(key) || + "Key must be lowercase with words separated by hyphens", + }, +}; + +function nextAvailableKey( + baseKey: string, + format: KeyFormat, + existingKeys: string[], + attempt: number = 1, +): string { + const separator = format.includes("snake") ? "_" : "-"; + const suggestedKey = + attempt === 1 ? baseKey : `${baseKey}${separator}${attempt}`; + + return existingKeys.includes(suggestedKey) + ? nextAvailableKey(baseKey, format, existingKeys, attempt + 1) + : suggestedKey; +} + +export function genFeatureKey( + input: string, + format: KeyFormat, + existingKeys: string[], +): string { + const key = KeyFormatPatterns[format].transform(input); + return nextAvailableKey(key, format, existingKeys); +} export const genDTS = (keys: string[]) => { return /* ts */ ` // DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. - +// eslint-disable +// prettier-ignore declare module "@bucketco/react-sdk" { interface Features { -${keys.map((key) => ` ${key}: boolean;`).join("\n")} +${keys.map((key) => ` "${key}": boolean;`).join("\n")} } } `.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/utils/options.ts b/packages/cli/utils/options.ts index 4b265ab2..7092331b 100644 --- a/packages/cli/utils/options.ts +++ b/packages/cli/utils/options.ts @@ -1,10 +1,47 @@ -import { Option } from "commander"; -import { getConfig } from "./config.js"; -import { CONFIG_FILE_NAME } from "./constants.js"; +import { + CONFIG_FILE_NAME, + DEFAULT_BASE_URL, + DEFAULT_TYPES_PATH, +} from "./constants.js"; -export const appIdOption = new Option( - "-a, --appId ", - `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE_NAME}.`, -) - .default(getConfig("appId"), "Bucket Application ID") - .makeOptionMandatory(); +export const options = { + debug: { + flags: "--debug", + description: "Enable debug mode", + }, + baseUrl: { + flags: "--base-url [url]", + description: "Specify the Bucket service URL (useful if behind a proxy).", + configKey: "baseUrl", + fallback: DEFAULT_BASE_URL, + }, + apiUrl: { + flags: "--api-url [url]", + description: `Specify the Bucket API URL (useful if behind a proxy). Falls back to apiUrl value in ${CONFIG_FILE_NAME} or baseUrl with /api appended.`, + configKey: "apiUrl", + }, + appId: { + flags: "-a, --appId ", + description: `Specify the app ID. Falls back to appId value in ${CONFIG_FILE_NAME}.`, + configKey: "appId", + }, + initOverride: { + flags: "-f, --force", + description: "Force initialization and overwrite existing configuration.", + }, + typesOut: { + flags: "-o, --out [path]", + description: `Specify the output path for generated feature types. Falls back to typesPath value in ${CONFIG_FILE_NAME} or ${DEFAULT_TYPES_PATH}`, + configKey: "typesPath", + fallback: DEFAULT_TYPES_PATH, + }, + featureName: { + flags: "[name]", + description: "Specify the feature's name.", + }, + featureKey: { + flags: "-k, --key [feature key]", + description: + "Specify the feature key. If not provided, a key is generated from the feature's name.", + }, +} as const; diff --git a/yarn.lock b/yarn.lock index 5b03c552..0e7a51dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -933,19 +933,16 @@ __metadata: "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@inquirer/prompts": "npm:^5.3.8" - "@sindresorhus/slugify": "npm:^2.2.1" - "@types/fs-extra": "npm:^11.0.4" "@types/node": "npm:^22.5.1" ajv: "npm:^8.17.1" axios: "npm:^1.7.5" chalk: "npm:^5.3.0" + change-case: "npm:^5.4.4" commander: "npm:^12.1.0" find-up: "npm:^7.0.0" - fs-extra: "npm:^11.2.0" json5: "npm:^2.2.3" open: "npm:^10.1.0" ora: "npm:^8.1.0" - ts-node: "npm:^10.9.2" typescript: "npm:^5.5.4" bin: bucket: ./dist/index.js @@ -3802,25 +3799,6 @@ __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" @@ -3983,16 +3961,6 @@ __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" @@ -4041,15 +4009,6 @@ __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" @@ -6174,6 +6133,13 @@ __metadata: languageName: node linkType: hard +"change-case@npm:^5.4.4": + version: 5.4.4 + resolution: "change-case@npm:5.4.4" + checksum: 10c0/2a9c2b9c9ad6ab2491105aaf506db1a9acaf543a18967798dcce20926c6a173aa63266cb6189f3086e3c14bf7ae1f8ea4f96ecc466fcd582310efa00372f3734 + languageName: node + linkType: hard + "chardet@npm:^0.7.0": version: 0.7.0 resolution: "chardet@npm:0.7.0" @@ -7743,13 +7709,6 @@ __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" @@ -8529,7 +8488,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^11.1.0, fs-extra@npm:^11.1.1, fs-extra@npm:^11.2.0": +"fs-extra@npm:^11.1.0, fs-extra@npm:^11.1.1": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" dependencies: From dc8db1151b3131cefee4e68a599157bfb5835f04 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 24 Feb 2025 15:57:57 +0100 Subject: [PATCH 09/60] fix: remove incorrectly committed config file --- .gitignore | 1 + packages/cli/bucket.config.json | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 packages/cli/bucket.config.json diff --git a/.gitignore b/.gitignore index abe7f314..c25132d4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ junit.xml .next eslint-report.json +bucket.config.json diff --git a/packages/cli/bucket.config.json b/packages/cli/bucket.config.json deleted file mode 100644 index 96bbdbcc..00000000 --- a/packages/cli/bucket.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://unpkg.com/@bucketco/cli@0.0.1/schema.json", - "appId": "apbW6RrMgL2IhC", - "baseUrl": "https://staging.app.bucket.co", - "keyFormat": "snakeCaseUppers" -} From 07169dca5f96fb2e99783eb1df17037a0c88217e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 24 Feb 2025 16:18:42 +0100 Subject: [PATCH 10/60] fix: logs and spinners --- packages/cli/commands/auth.ts | 4 +-- packages/cli/commands/features.ts | 50 +++++++++++++++++++----------- packages/cli/commands/init.ts | 31 +++++++++++------- packages/cli/services/bootstrap.ts | 23 ++++++++------ packages/cli/services/features.ts | 8 +++-- packages/cli/utils/config.ts | 2 +- 6 files changed, 74 insertions(+), 44 deletions(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 5d9813c8..08146f5e 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -12,7 +12,7 @@ export const loginAction = async () => { await authenticateUser(); spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully! 🎉`); } catch (error) { - spinner.fail(); + spinner.fail("Login failed"); handleError(error, "Login"); } }; @@ -23,7 +23,7 @@ export const logoutAction = async () => { await storeToken(""); spinner.succeed("Logged out successfully! 👋"); } catch (error) { - spinner.fail(); + spinner.fail("Logout failed"); handleError(error, "Logout"); } }; diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 98afbca5..021ec9bb 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -2,7 +2,7 @@ import chalk from "chalk"; import { Command, program } from "commander"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join, isAbsolute } from "node:path"; -import ora from "ora"; +import ora, { Ora } from "ora"; import { createFeature, listFeatures } from "../services/features.js"; import { getConfig, getProjectPath } from "../utils/config.js"; @@ -28,16 +28,23 @@ export const createFeatureAction = async ( { appId, key }: CreateFeatureArgs, ) => { const { baseUrl } = program.opts(); - const spinner = ora( - `Loading features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, - ).start(); + let spinner: Ora | undefined; + let existingKeys: string[] = []; try { + spinner = ora( + `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); const features = await listFeatures(appId); - const existingKeys = features.map((f) => f.key); + existingKeys = features.map((f) => f.key); spinner.succeed( - `Loaded features app ${chalk.cyan(appId)} at from ${chalk.cyan(baseUrl)}`, + `Loaded features of app ${chalk.cyan(appId)} at from ${chalk.cyan(baseUrl)}`, ); + } catch (error) { + spinner?.fail("Loading features failed"); + handleError(error, "Features Create"); + } + try { if (!name) { name = await input({ message: "New feature name:", @@ -54,13 +61,13 @@ export const createFeatureAction = async ( }); } - spinner.start("Creating feature..."); + spinner = ora("Creating feature...").start(); const feature = await createFeature(appId, name, key); spinner.succeed( `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}. 🎉`, ); } catch (error) { - spinner.fail(); + spinner?.fail("Feature creation failed"); handleError(error, "Features Create"); } }; @@ -68,16 +75,16 @@ export const createFeatureAction = async ( export const listFeaturesAction = async ({ appId }: AppIdArgs) => { const { baseUrl } = program.opts(); const spinner = ora( - `Loading features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, ).start(); try { const features = await listFeatures(appId); spinner.succeed( - `Loaded features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, + `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, ); console.table(features); } catch (error) { - spinner.fail(); + spinner.fail("Loading features failed"); handleError(error, "Features List"); } }; @@ -87,24 +94,31 @@ export const generateTypesAction = async ({ out, }: GenerateTypesArgs) => { const { baseUrl } = program.opts(); - let spinner = ora( - `Loading features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, - ).start(); + let spinner: Ora | undefined; + let featureKeys: string[] = []; try { - const feature = await listFeatures(appId); + spinner = ora( + `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); + featureKeys = (await listFeatures(appId)).map(({ key }) => key); spinner.succeed( - `Loaded features from app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, + `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, ); + } catch (error) { + spinner?.fail("Loading features failed"); + handleError(error, "Features Types"); + } + try { spinner = ora("Generating feature types...").start(); - const types = genDTS(feature.map(({ key }) => key)); + const types = genDTS(featureKeys); const outPath = isAbsolute(out) ? out : join(getProjectPath(), out); await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, types); spinner.succeed("Generated feature types successfully"); console.log(chalk.green(`Generated types for ${appId}.`)); } catch (error) { - spinner.fail(); + spinner?.fail("Type generation failed"); handleError(error, "Features Types"); } }; diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index 6688e51c..b3100f21 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -3,12 +3,13 @@ import chalk from "chalk"; import { Command, program } from "commander"; import ora, { Ora } from "ora"; -import { listApps } from "../services/bootstrap.js"; +import { App, listApps } from "../services/bootstrap.js"; import { createConfig, getConfigPath } from "../utils/config.js"; import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js"; import { handleError } from "../utils/error.js"; import { options } from "../utils/options.js"; +import { relative } from "node:path"; type InitArgs = { force?: boolean; @@ -16,28 +17,32 @@ type InitArgs = { export const initAction = async (args: InitArgs) => { let spinner: Ora | undefined; + let apps: App[] = []; try { // Check if already initialized const configPath = getConfigPath(); - if (configPath) { - if (!args.force) { - throw new Error( - "Bucket is already initialized. Use --force to overwrite.", - ); - } + if (configPath && !args.force) { + throw new Error( + "Bucket is already initialized. Use --force to overwrite.", + ); } console.log(chalkBrand("\nWelcome to Bucket! 🪣\n")); - const { baseUrl, apiUrl } = program.opts(); + const { baseUrl } = program.opts(); // Load apps spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); - const apps = await listApps(); + apps = await listApps(); spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); + } catch (error) { + spinner?.fail("Loading apps failed"); + handleError(error, "Initialization"); + } + try { + const { baseUrl, apiUrl } = program.opts(); let appId: string | undefined; - const nonDemoApps = apps.filter((app) => !app.demo); // If there is only one non-demo app, select it automatically @@ -80,9 +85,11 @@ export const initAction = async (args: InitArgs) => { }, args.force, ); - spinner.succeed(`Configuration created at ${getConfigPath()}! 🎉`); + spinner.succeed( + `Configuration created at ${chalk.cyan(relative(process.cwd(), getConfigPath()!))}`, + ); } catch (error) { - spinner?.fail(); + spinner?.fail("Configuration creation failed"); handleError(error, "Initialization"); } }; diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts index bf128773..16045e7b 100644 --- a/packages/cli/services/bootstrap.ts +++ b/packages/cli/services/bootstrap.ts @@ -1,20 +1,25 @@ import { authRequest } from "../utils/auth.js"; import { KeyFormat } from "../utils/config.js"; -type App = { - id: string; - name: string; - demo: boolean; -}; - type BootstrapResponse = { org: { - apps: App[]; + apps: { + id: string; + name: string; + demo: boolean; + }[]; featureKeyFormat?: KeyFormat; }; }; -export async function listApps() { +export type App = { + id: string; + name: string; + demo: boolean; + featureKeyFormat: KeyFormat; +}; + +export async function listApps(): Promise { const response = await authRequest(`/bootstrap`); const org = response.org; if (!org) { @@ -27,6 +32,6 @@ export async function listApps() { id, name, demo, - featureKeyFormat: org.featureKeyFormat, + featureKeyFormat: org.featureKeyFormat ?? "custom", })); } diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 4ddb92ff..85c67b5f 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -9,7 +9,7 @@ type FeaturesResponse = { data: Feature[]; }; -export async function listFeatures(appId: string) { +export async function listFeatures(appId: string): Promise { const response = await authRequest( `/apps/${appId}/features`, ); @@ -24,7 +24,11 @@ type FeatureResponse = { feature: Feature; }; -export async function createFeature(appId: string, name: string, key: string) { +export async function createFeature( + appId: string, + name: string, + key: string, +): Promise { const response = await authRequest( `/apps/${appId}/features`, { diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index f25da7cf..1cc28c09 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -148,7 +148,7 @@ export async function createConfig(newConfig: Config, overwrite = false) { config = newConfig; } else { // Write to the nearest package.json directory - const configPath = join(getProjectPath(), CONFIG_FILE_NAME); + configPath = join(getProjectPath(), CONFIG_FILE_NAME); await writeFile(configPath, configJSON); config = newConfig; } From dbda322fe9821d1df0ef4f548d460add389daaa6 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 25 Feb 2025 11:54:30 +0100 Subject: [PATCH 11/60] feat: removed axios --- packages/cli/package.json | 4 +++- packages/cli/utils/auth.ts | 34 +++++++++++++++------------------- packages/cli/utils/error.ts | 7 +++---- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index bbfff8d5..e2c8720f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,6 +5,9 @@ "description": "CLI for Bucket service", "main": "./dist/index.js", "type": "module", + "engines": { + "node": ">=18.0.0" + }, "bin": { "bucket": "./dist/index.js" }, @@ -19,7 +22,6 @@ "dependencies": { "@inquirer/prompts": "^5.3.8", "ajv": "^8.17.1", - "axios": "^1.7.5", "chalk": "^5.3.0", "change-case": "^5.4.4", "commander": "^12.1.0", diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index a93d3981..11f69432 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,5 +1,4 @@ import http from "http"; -import axios, { AxiosError, AxiosRequestConfig } from "axios"; import open from "open"; import { AUTH_FILE, loginUrl } from "./constants.js"; @@ -114,34 +113,31 @@ export async function authenticateUser() { export async function authRequest>( url: string, - options?: AxiosRequestConfig, + options?: RequestInit, retryCount = 0, ): Promise { const token = await getToken(); let { baseUrl, apiUrl } = program.opts(); apiUrl = apiUrl ?? `${baseUrl}/api`; - try { - const response = await axios({ - ...options, - url: `${apiUrl}${url}`, - headers: { - ...options?.headers, - Authorization: `Bearer ${token}`, - }, - }); - return response.data; - } catch (error) { - if ( - error instanceof AxiosError && - error.response && - error.response.status === 401 - ) { + + const response = await fetch(`${apiUrl}${url}`, { + ...options, + headers: { + ...options?.headers, + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { await storeToken(""); if (retryCount < 1) { await authenticateUser(); return authRequest(url, options, retryCount + 1); } } - throw error; + throw response; } + + return response.json(); } diff --git a/packages/cli/utils/error.ts b/packages/cli/utils/error.ts index 6d457976..63a8276b 100644 --- a/packages/cli/utils/error.ts +++ b/packages/cli/utils/error.ts @@ -1,11 +1,10 @@ -import { AxiosError } from "axios"; import chalk from "chalk"; -export function handleError(error: unknown, tag: string) { +export async function handleError(error: unknown, tag: string) { tag = chalk.bold(`[${tag}] error:`); - if (error instanceof AxiosError && error.response?.data) { - const data = error.response.data; + if (error instanceof Response) { + const data = await error.json(); console.error(chalk.red(tag, data.error?.message ?? data.error?.code)); if (data.validationErrors) { console.table( From b7d592fbffc8b92e387fc12f75e5bdc42713f72e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 25 Feb 2025 12:55:04 +0100 Subject: [PATCH 12/60] feat: allow multiple baseUrls --- packages/cli/commands/auth.ts | 5 ++-- packages/cli/commands/init.ts | 4 +-- packages/cli/index.ts | 4 ++- packages/cli/services/features.ts | 4 +-- packages/cli/utils/auth.ts | 48 +++++++++++++++++++++++++------ packages/cli/utils/config.ts | 2 +- yarn.lock | 12 -------- 7 files changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 08146f5e..3b3bc57c 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -2,7 +2,7 @@ import { Command, program } from "commander"; import ora from "ora"; import chalk from "chalk"; -import { authenticateUser, storeToken } from "../utils/auth.js"; +import { authenticateUser, setToken } from "../utils/auth.js"; import { handleError } from "../utils/error.js"; export const loginAction = async () => { @@ -18,9 +18,10 @@ export const loginAction = async () => { }; export const logoutAction = async () => { + const { baseUrl } = program.opts(); const spinner = ora("Logging out...").start(); try { - await storeToken(""); + await setToken(baseUrl, undefined); spinner.succeed("Logged out successfully! 👋"); } catch (error) { spinner.fail("Logout failed"); diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index b3100f21..bc880433 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -4,7 +4,7 @@ import { Command, program } from "commander"; import ora, { Ora } from "ora"; import { App, listApps } from "../services/bootstrap.js"; -import { createConfig, getConfigPath } from "../utils/config.js"; +import { saveConfig, getConfigPath } from "../utils/config.js"; import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js"; import { handleError } from "../utils/error.js"; @@ -75,7 +75,7 @@ export const initAction = async (args: InitArgs) => { // Create config file spinner = ora("Creating configuration...").start(); - await createConfig( + await saveConfig( { baseUrl, apiUrl, diff --git a/packages/cli/index.ts b/packages/cli/index.ts index e6273a8f..f38aaa09 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -9,9 +9,11 @@ import { registerAppCommands } from "./commands/apps.js"; import chalk from "chalk"; import { registerNewCommand } from "./commands/new.js"; import { options } from "./utils/options.js"; +import { loadTokens } from "./utils/auth.js"; async function main() { - // Must load config before anything else + // Must load tokens and config before anything else + await loadTokens(); await loadConfig(); // Global options diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 85c67b5f..c803ee0d 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -33,11 +33,11 @@ export async function createFeature( `/apps/${appId}/features`, { method: "POST", - data: { + body: JSON.stringify({ name, key, source: "event", - }, + }), }, ); return response.feature; diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 11f69432..a5cb695a 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,18 +1,45 @@ import http from "http"; import open from "open"; - import { AUTH_FILE, loginUrl } from "./constants.js"; import { mkdir, readFile, writeFile } from "fs/promises"; import { dirname } from "path"; import { program } from "commander"; -export async function getToken() { - return readFile(AUTH_FILE, "utf-8"); +let tokens: Map = new Map(); + +export async function loadTokens() { + try { + const content = await readFile(AUTH_FILE, "utf-8"); + tokens = new Map( + content + .split("\n") + .filter(Boolean) + .map((line) => { + const [baseUrl, token] = line.split("|"); + return [baseUrl, token]; + }), + ); + } catch (error) { + // No tokens file found + } } -export async function storeToken(newToken: string) { +async function saveTokens(newTokens: Map) { + const content = Array.from(newTokens.entries()) + .map(([baseUrl, token]) => `${baseUrl}|${token}`) + .join("\n"); await mkdir(dirname(AUTH_FILE), { recursive: true }); - await writeFile(AUTH_FILE, newToken); + await writeFile(AUTH_FILE, content); + tokens = newTokens; +} + +export async function setToken(baseUrl: string, newToken?: string) { + if (newToken) { + tokens.set(baseUrl, newToken); + } else { + tokens.delete(baseUrl); + } + await saveTokens(tokens); } function corsHeaders(origin: string): Record { @@ -63,7 +90,7 @@ export async function authenticateUser() { res.end(JSON.stringify({ result: "success" })); try { - await storeToken(token); + await setToken(baseUrl, token); cleanupAndResolve(token); } catch (error) { cleanupAndReject( @@ -116,10 +143,15 @@ export async function authRequest>( options?: RequestInit, retryCount = 0, ): Promise { - const token = await getToken(); let { baseUrl, apiUrl } = program.opts(); + const token = tokens.get(baseUrl); apiUrl = apiUrl ?? `${baseUrl}/api`; + if (!token) { + await authenticateUser(); + return authRequest(url, options); + } + const response = await fetch(`${apiUrl}${url}`, { ...options, headers: { @@ -130,7 +162,7 @@ export async function authRequest>( if (!response.ok) { if (response.status === 401) { - await storeToken(""); + await setToken(baseUrl, undefined); if (retryCount < 1) { await authenticateUser(); return authRequest(url, options, retryCount + 1); diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 1cc28c09..cd1b5f42 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -117,7 +117,7 @@ const getDefaultConfig = (): Partial => ({ keyFormat: "custom", }); -export async function createConfig(newConfig: Config, overwrite = false) { +export async function saveConfig(newConfig: Config, overwrite = false) { if (!validateConfig(newConfig)) { handleError(new ConfigValidationError(validateConfig.errors), "Config"); } diff --git a/yarn.lock b/yarn.lock index 0e7a51dc..3b24506d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -935,7 +935,6 @@ __metadata: "@inquirer/prompts": "npm:^5.3.8" "@types/node": "npm:^22.5.1" ajv: "npm:^8.17.1" - axios: "npm:^1.7.5" chalk: "npm:^5.3.0" change-case: "npm:^5.4.4" commander: "npm:^12.1.0" @@ -5659,17 +5658,6 @@ __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" From b844eee639f94383457b7b0a1023e2d536356790 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 25 Feb 2025 14:04:18 +0100 Subject: [PATCH 13/60] refactor: minor --- packages/cli/utils/auth.ts | 11 ++++++++--- packages/cli/utils/constants.ts | 6 +++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index a5cb695a..4f99bec4 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -33,6 +33,10 @@ async function saveTokens(newTokens: Map) { tokens = newTokens; } +export function getToken(baseUrl: string) { + return tokens.get(baseUrl); +} + export async function setToken(baseUrl: string, newToken?: string) { if (newToken) { tokens.set(baseUrl, newToken); @@ -42,9 +46,9 @@ export async function setToken(baseUrl: string, newToken?: string) { await saveTokens(tokens); } -function corsHeaders(origin: string): Record { +function corsHeaders(baseUrl: string): Record { return { - "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Origin": baseUrl, "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Authorization", }; @@ -144,7 +148,8 @@ export async function authRequest>( retryCount = 0, ): Promise { let { baseUrl, apiUrl } = program.opts(); - const token = tokens.get(baseUrl); + const token = getToken(baseUrl); + console.log(token); apiUrl = apiUrl ?? `${baseUrl}/api`; if (!token) { diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 95c357ec..ca2ebbca 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,4 +1,4 @@ -import path from "path"; +import { join } from "path"; import { createRequire } from "module"; import chalk from "chalk"; @@ -6,7 +6,7 @@ import chalk from "chalk"; const packageJson = createRequire(import.meta.url)("../../package.json"); export const CONFIG_FILE_NAME = "bucket.config.json"; -export const AUTH_FILE = path.join( +export const AUTH_FILE = join( process.env.HOME ?? process.env.USERPROFILE ?? "", ".bucket-auth", ); @@ -14,7 +14,7 @@ export const SCHEMA_URL = `https://unpkg.com/@bucketco/cli@${packageJson.version export const DEFAULT_BASE_URL = "https://app.bucket.co"; export const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/api`; -export const DEFAULT_TYPES_PATH = path.join("gen", "features.ts"); +export const DEFAULT_TYPES_PATH = join("gen", "features.ts"); export const loginUrl = (baseUrl: string, localPort: number) => `${baseUrl}/login?redirect_url=` + From e148e63577d0921902d82115c02aed707284404b Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 25 Feb 2025 14:41:28 +0100 Subject: [PATCH 14/60] style: fix linting errors --- packages/cli/commands/apps.ts | 2 +- packages/cli/commands/auth.ts | 2 +- packages/cli/commands/features.ts | 4 ++-- packages/cli/commands/init.ts | 5 ++--- packages/cli/commands/new.ts | 8 +++++--- packages/cli/eslint.config.js | 4 ++-- packages/cli/index.ts | 8 ++++---- packages/cli/package.json | 8 +++++++- packages/cli/utils/auth.ts | 16 +++++++-------- packages/cli/utils/config.ts | 33 ++++++++++++++----------------- packages/cli/utils/constants.ts | 2 +- 11 files changed, 48 insertions(+), 44 deletions(-) diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index 2fffd64e..8b4c4ee8 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -1,9 +1,9 @@ +import chalk from "chalk"; import { Command, program } from "commander"; import ora from "ora"; import { listApps } from "../services/bootstrap.js"; import { handleError } from "../utils/error.js"; -import chalk from "chalk"; export const listAppsAction = async () => { const { baseUrl } = program.opts(); diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 3b3bc57c..526773bf 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -1,6 +1,6 @@ +import chalk from "chalk"; import { Command, program } from "commander"; import ora from "ora"; -import chalk from "chalk"; import { authenticateUser, setToken } from "../utils/auth.js"; import { handleError } from "../utils/error.js"; diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 021ec9bb..f58b9c33 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -1,13 +1,13 @@ +import { input } from "@inquirer/prompts"; import chalk from "chalk"; import { Command, program } from "commander"; import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join, isAbsolute } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import ora, { Ora } from "ora"; import { createFeature, listFeatures } from "../services/features.js"; import { getConfig, getProjectPath } from "../utils/config.js"; import { handleError } from "../utils/error.js"; -import { input } from "@inquirer/prompts"; import { genDTS, genFeatureKey, KeyFormatPatterns } from "../utils/gen.js"; import { options } from "../utils/options.js"; diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index bc880433..9fe8f0f4 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -1,15 +1,14 @@ import { input, select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command, program } from "commander"; +import { relative } from "node:path"; import ora, { Ora } from "ora"; import { App, listApps } from "../services/bootstrap.js"; -import { saveConfig, getConfigPath } from "../utils/config.js"; - +import { getConfigPath, saveConfig } from "../utils/config.js"; import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js"; import { handleError } from "../utils/error.js"; import { options } from "../utils/options.js"; -import { relative } from "node:path"; type InitArgs = { force?: boolean; diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts index 0e8ffa2f..7b1e8b4f 100644 --- a/packages/cli/commands/new.ts +++ b/packages/cli/commands/new.ts @@ -1,12 +1,14 @@ import { Command } from "commander"; import { findUp } from "find-up"; + +import { getConfig } from "../utils/config.js"; import { CONFIG_FILE_NAME } from "../utils/constants.js"; -import { initAction } from "./init.js"; -import { createFeatureAction, generateTypesAction } from "./features.js"; import { handleError } from "../utils/error.js"; -import { getConfig } from "../utils/config.js"; import { options } from "../utils/options.js"; +import { createFeatureAction, generateTypesAction } from "./features.js"; +import { initAction } from "./init.js"; + type NewArgs = { appId?: string; out: string; diff --git a/packages/cli/eslint.config.js b/packages/cli/eslint.config.js index cc05170e..fbe7855a 100644 --- a/packages/cli/eslint.config.js +++ b/packages/cli/eslint.config.js @@ -1,3 +1,3 @@ -const base = require("@bucketco/eslint-config/base"); +import base from "@bucketco/eslint-config/base.js"; -module.exports = [...base, { ignores: ["dist/", "gen/"] }]; +export default [...base, { ignores: ["dist/", "gen/"] }]; diff --git a/packages/cli/index.ts b/packages/cli/index.ts index f38aaa09..07669503 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,15 +1,15 @@ #!/usr/bin/env node --no-warnings=ExperimentalWarning +import chalk from "chalk"; import { program } from "commander"; +import { registerAppCommands } from "./commands/apps.js"; import { registerAuthCommands } from "./commands/auth.js"; import { registerFeatureCommands } from "./commands/features.js"; import { registerInitCommand } from "./commands/init.js"; -import { getConfig, getConfigPath, loadConfig } from "./utils/config.js"; -import { registerAppCommands } from "./commands/apps.js"; -import chalk from "chalk"; import { registerNewCommand } from "./commands/new.js"; -import { options } from "./utils/options.js"; import { loadTokens } from "./utils/auth.js"; +import { getConfig, getConfigPath, loadConfig } from "./utils/config.js"; +import { options } from "./utils/options.js"; async function main() { // Must load tokens and config before anything else diff --git a/packages/cli/package.json b/packages/cli/package.json index e2c8720f..c4e2b7b9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,7 +17,11 @@ ], "scripts": { "build": "tsc", - "bucket": "yarn build && node dist/index.js" + "bucket": "yarn build && node dist/index.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": "^5.3.8", @@ -34,6 +38,8 @@ "@bucketco/eslint-config": "workspace:^", "@bucketco/tsconfig": "workspace:^", "@types/node": "^22.5.1", + "eslint": "^9.21.0", + "prettier": "^3.5.2", "typescript": "^5.5.4" } } diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 4f99bec4..b3835138 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,9 +1,10 @@ -import http from "http"; -import open from "open"; -import { AUTH_FILE, loginUrl } from "./constants.js"; import { mkdir, readFile, writeFile } from "fs/promises"; +import http from "http"; import { dirname } from "path"; import { program } from "commander"; +import open from "open"; + +import { AUTH_FILE, loginUrl } from "./constants.js"; let tokens: Map = new Map(); @@ -19,7 +20,7 @@ export async function loadTokens() { return [baseUrl, token]; }), ); - } catch (error) { + } catch { // No tokens file found } } @@ -147,17 +148,16 @@ export async function authRequest>( options?: RequestInit, retryCount = 0, ): Promise { - let { baseUrl, apiUrl } = program.opts(); + const { baseUrl, apiUrl } = program.opts(); const token = getToken(baseUrl); - console.log(token); - apiUrl = apiUrl ?? `${baseUrl}/api`; + const resolvedApiUrl = apiUrl ?? `${baseUrl}/api`; if (!token) { await authenticateUser(); return authRequest(url, options); } - const response = await fetch(`${apiUrl}${url}`, { + const response = await fetch(`${resolvedApiUrl}${url}`, { ...options, headers: { ...options?.headers, diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index cd1b5f42..4a007957 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -1,7 +1,10 @@ import { readFile, writeFile } from "fs/promises"; +import { createRequire } from "module"; +import { dirname, join } from "path"; import { Ajv } from "ajv"; +import { findUp } from "find-up"; import JSON5 from "json5"; -import { createRequire } from "module"; + import { CONFIG_FILE_NAME, DEFAULT_API_URL, @@ -9,9 +12,7 @@ import { DEFAULT_TYPES_PATH, SCHEMA_URL, } from "./constants.js"; -import { findUp } from "find-up"; import { handleError } from "./error.js"; -import { dirname, join } from "path"; // https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 const schema = createRequire(import.meta.url)("../../schema.json"); @@ -100,7 +101,7 @@ export async function loadConfig() { handleError(new ConfigValidationError(validateConfig.errors), "Config"); } config = parsed; - } catch (error) { + } catch { // No config file found } } @@ -139,20 +140,16 @@ export async function saveConfig(newConfig: Config, overwrite = false) { const configJSON = JSON.stringify(configWithoutDefaults, null, 2); - try { - if (configPath) { - if (!overwrite) { - throw new Error("Config file already exists"); - } - await writeFile(configPath, configJSON); - config = newConfig; - } else { - // Write to the nearest package.json directory - configPath = join(getProjectPath(), CONFIG_FILE_NAME); - await writeFile(configPath, configJSON); - config = newConfig; + if (configPath) { + if (!overwrite) { + throw new Error("Config file already exists"); } - } catch (error) { - throw error; + await writeFile(configPath, configJSON); + config = newConfig; + } else { + // Write to the nearest package.json directory + configPath = join(getProjectPath(), CONFIG_FILE_NAME); + await writeFile(configPath, configJSON); + config = newConfig; } } diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index ca2ebbca..855f2dc7 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,5 +1,5 @@ -import { join } from "path"; import { createRequire } from "module"; +import { join } from "path"; import chalk from "chalk"; // https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 From 51fa0a8e87a905888cd917e689a8b99d23b95f07 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 25 Feb 2025 15:10:57 +0100 Subject: [PATCH 15/60] fix: errors and post request --- packages/cli/services/features.ts | 3 + packages/cli/utils/error.ts | 2 +- packages/eslint-config/base.js | 2 +- yarn.lock | 265 +++++++++++++++++++++++++++++- 4 files changed, 268 insertions(+), 4 deletions(-) diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index c803ee0d..a0763baf 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -33,6 +33,9 @@ export async function createFeature( `/apps/${appId}/features`, { method: "POST", + headers: { + "Content-Type": "application/json", + }, body: JSON.stringify({ name, key, diff --git a/packages/cli/utils/error.ts b/packages/cli/utils/error.ts index 63a8276b..786ef687 100644 --- a/packages/cli/utils/error.ts +++ b/packages/cli/utils/error.ts @@ -1,7 +1,7 @@ import chalk from "chalk"; export async function handleError(error: unknown, tag: string) { - tag = chalk.bold(`[${tag}] error:`); + tag = chalk.bold(`\n[${tag}] error:`); if (error instanceof Response) { const data = await error.json(); diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 72fa2a92..3da6ccb5 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -100,7 +100,7 @@ module.exports = [ languageOptions: { parser: tsParser, parserOptions: { - project: "./tsconfig.json", + project: "./tsconfig.eslint.json", }, }, settings: { diff --git a/yarn.lock b/yarn.lock index 3b24506d..0dc7f63d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -938,10 +938,12 @@ __metadata: chalk: "npm:^5.3.0" change-case: "npm:^5.4.4" commander: "npm:^12.1.0" + eslint: "npm:^9.21.0" find-up: "npm:^7.0.0" json5: "npm:^2.2.3" open: "npm:^10.1.0" ora: "npm:^8.1.0" + prettier: "npm:^3.5.2" typescript: "npm:^5.5.4" bin: bucket: ./dist/index.js @@ -1774,6 +1776,13 @@ __metadata: languageName: node linkType: hard +"@eslint-community/regexpp@npm:^4.12.1": + version: 4.12.1 + resolution: "@eslint-community/regexpp@npm:4.12.1" + checksum: 10c0/a03d98c246bcb9109aec2c08e4d10c8d010256538dcb3f56610191607214523d4fb1b00aa81df830b6dffb74c5fa0be03642513a289c567949d3e550ca11cdf6 + languageName: node + linkType: hard + "@eslint-community/regexpp@npm:^4.6.1": version: 4.6.2 resolution: "@eslint-community/regexpp@npm:4.6.2" @@ -1781,6 +1790,26 @@ __metadata: languageName: node linkType: hard +"@eslint/config-array@npm:^0.19.2": + version: 0.19.2 + resolution: "@eslint/config-array@npm:0.19.2" + dependencies: + "@eslint/object-schema": "npm:^2.1.6" + debug: "npm:^4.3.1" + minimatch: "npm:^3.1.2" + checksum: 10c0/dd68da9abb32d336233ac4fe0db1e15a0a8d794b6e69abb9e57545d746a97f6f542496ff9db0d7e27fab1438546250d810d90b1904ac67677215b8d8e7573f3d + languageName: node + linkType: hard + +"@eslint/core@npm:^0.12.0": + version: 0.12.0 + resolution: "@eslint/core@npm:0.12.0" + dependencies: + "@types/json-schema": "npm:^7.0.15" + checksum: 10c0/d032af81195bb28dd800c2b9617548c6c2a09b9490da3c5537fd2a1201501666d06492278bb92cfccac1f7ac249e58601dd87f813ec0d6a423ef0880434fa0c3 + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -1798,6 +1827,23 @@ __metadata: languageName: node linkType: hard +"@eslint/eslintrc@npm:^3.3.0": + version: 3.3.0 + resolution: "@eslint/eslintrc@npm:3.3.0" + dependencies: + ajv: "npm:^6.12.4" + debug: "npm:^4.3.2" + espree: "npm:^10.0.1" + globals: "npm:^14.0.0" + ignore: "npm:^5.2.0" + import-fresh: "npm:^3.2.1" + js-yaml: "npm:^4.1.0" + minimatch: "npm:^3.1.2" + strip-json-comments: "npm:^3.1.1" + checksum: 10c0/215de990231b31e2fe6458f225d8cea0f5c781d3ecb0b7920703501f8cd21b3101fc5ef2f0d4f9a38865d36647b983e0e8ce8bf12fd2bcdd227fc48a5b1a43be + languageName: node + linkType: hard + "@eslint/js@npm:8.56.0": version: 8.56.0 resolution: "@eslint/js@npm:8.56.0" @@ -1812,6 +1858,13 @@ __metadata: languageName: node linkType: hard +"@eslint/js@npm:9.21.0": + version: 9.21.0 + resolution: "@eslint/js@npm:9.21.0" + checksum: 10c0/86c24a2668808995037e3f40c758335df2ae277c553ac0cf84381a1a8698f3099d8a22dd9c388947e6b7f93fcc1142f62406072faaa2b83c43ca79993fc01bb3 + languageName: node + linkType: hard + "@eslint/js@npm:^8.57.0": version: 8.57.1 resolution: "@eslint/js@npm:8.57.1" @@ -1819,6 +1872,23 @@ __metadata: languageName: node linkType: hard +"@eslint/object-schema@npm:^2.1.6": + version: 2.1.6 + resolution: "@eslint/object-schema@npm:2.1.6" + checksum: 10c0/b8cdb7edea5bc5f6a96173f8d768d3554a628327af536da2fc6967a93b040f2557114d98dbcdbf389d5a7b290985ad6a9ce5babc547f36fc1fde42e674d11a56 + languageName: node + linkType: hard + +"@eslint/plugin-kit@npm:^0.2.7": + version: 0.2.7 + resolution: "@eslint/plugin-kit@npm:0.2.7" + dependencies: + "@eslint/core": "npm:^0.12.0" + levn: "npm:^0.4.1" + checksum: 10c0/0a1aff1ad63e72aca923217e556c6dfd67d7cd121870eb7686355d7d1475d569773528a8b2111b9176f3d91d2ea81f7413c34600e8e5b73d59e005d70780b633 + languageName: node + linkType: hard + "@floating-ui/core@npm:^1.4.1": version: 1.4.1 resolution: "@floating-ui/core@npm:1.4.1" @@ -1882,6 +1952,23 @@ __metadata: languageName: node linkType: hard +"@humanfs/core@npm:^0.19.1": + version: 0.19.1 + resolution: "@humanfs/core@npm:0.19.1" + checksum: 10c0/aa4e0152171c07879b458d0e8a704b8c3a89a8c0541726c6b65b81e84fd8b7564b5d6c633feadc6598307d34564bd53294b533491424e8e313d7ab6c7bc5dc67 + languageName: node + linkType: hard + +"@humanfs/node@npm:^0.16.6": + version: 0.16.6 + resolution: "@humanfs/node@npm:0.16.6" + dependencies: + "@humanfs/core": "npm:^0.19.1" + "@humanwhocodes/retry": "npm:^0.3.0" + checksum: 10c0/8356359c9f60108ec204cbd249ecd0356667359b2524886b357617c4a7c3b6aace0fd5a369f63747b926a762a88f8a25bc066fa1778508d110195ce7686243e1 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.13, @humanwhocodes/config-array@npm:^0.11.14": version: 0.11.14 resolution: "@humanwhocodes/config-array@npm:0.11.14" @@ -1907,6 +1994,20 @@ __metadata: languageName: node linkType: hard +"@humanwhocodes/retry@npm:^0.3.0": + version: 0.3.1 + resolution: "@humanwhocodes/retry@npm:0.3.1" + checksum: 10c0/f0da1282dfb45e8120480b9e2e275e2ac9bbe1cf016d046fdad8e27cc1285c45bb9e711681237944445157b430093412b4446c1ab3fc4bb037861b5904101d3b + languageName: node + linkType: hard + +"@humanwhocodes/retry@npm:^0.4.2": + version: 0.4.2 + resolution: "@humanwhocodes/retry@npm:0.4.2" + checksum: 10c0/0235525d38f243bee3bf8b25ed395fbf957fb51c08adae52787e1325673071abe856c7e18e530922ed2dd3ce12ed82ba01b8cee0279ac52a3315fcdc3a69ef0c + languageName: node + linkType: hard + "@hutson/parse-repository-url@npm:^3.0.0": version: 3.0.2 resolution: "@hutson/parse-repository-url@npm:3.0.2" @@ -3946,7 +4047,7 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:1.0.6": +"@types/estree@npm:1.0.6, @types/estree@npm:^1.0.6": version: 1.0.6 resolution: "@types/estree@npm:1.0.6" checksum: 10c0/cdfd751f6f9065442cd40957c07fd80361c962869aa853c1c2fd03e101af8b9389d8ff4955a43a6fcfa223dd387a089937f95be0f3eec21ca527039fd2d9859a @@ -3994,6 +4095,13 @@ __metadata: languageName: node linkType: hard +"@types/json-schema@npm:^7.0.15": + version: 7.0.15 + resolution: "@types/json-schema@npm:7.0.15" + checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.8": version: 7.0.9 resolution: "@types/json-schema@npm:7.0.9" @@ -5061,6 +5169,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.14.0": + version: 8.14.0 + resolution: "acorn@npm:8.14.0" + bin: + acorn: bin/acorn + checksum: 10c0/6d4ee461a7734b2f48836ee0fbb752903606e576cc100eb49340295129ca0b452f3ba91ddd4424a1d4406a98adfb2ebb6bd0ff4c49d7a0930c10e462719bbfd7 + languageName: node + linkType: hard + "acorn@npm:^8.4.1": version: 8.7.0 resolution: "acorn@npm:8.7.0" @@ -6627,7 +6744,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -7979,6 +8096,16 @@ __metadata: languageName: node linkType: hard +"eslint-scope@npm:^8.2.0": + version: 8.2.0 + resolution: "eslint-scope@npm:8.2.0" + dependencies: + esrecurse: "npm:^4.3.0" + estraverse: "npm:^5.2.0" + checksum: 10c0/8d2d58e2136d548ac7e0099b1a90d9fab56f990d86eb518de1247a7066d38c908be2f3df477a79cf60d70b30ba18735d6c6e70e9914dca2ee515a729975d70d6 + languageName: node + linkType: hard + "eslint-visitor-keys@npm:^3.3.0, eslint-visitor-keys@npm:^3.4.1, eslint-visitor-keys@npm:^3.4.3": version: 3.4.3 resolution: "eslint-visitor-keys@npm:3.4.3" @@ -7986,6 +8113,13 @@ __metadata: languageName: node linkType: hard +"eslint-visitor-keys@npm:^4.2.0": + version: 4.2.0 + resolution: "eslint-visitor-keys@npm:4.2.0" + checksum: 10c0/2ed81c663b147ca6f578312919483eb040295bbab759e5a371953456c636c5b49a559883e2677112453728d66293c0a4c90ab11cab3428cf02a0236d2e738269 + languageName: node + linkType: hard + "eslint@npm:^8, eslint@npm:^8.57.0": version: 8.57.0 resolution: "eslint@npm:8.57.0" @@ -8034,6 +8168,55 @@ __metadata: languageName: node linkType: hard +"eslint@npm:^9.21.0": + version: 9.21.0 + resolution: "eslint@npm:9.21.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.2.0" + "@eslint-community/regexpp": "npm:^4.12.1" + "@eslint/config-array": "npm:^0.19.2" + "@eslint/core": "npm:^0.12.0" + "@eslint/eslintrc": "npm:^3.3.0" + "@eslint/js": "npm:9.21.0" + "@eslint/plugin-kit": "npm:^0.2.7" + "@humanfs/node": "npm:^0.16.6" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.4.2" + "@types/estree": "npm:^1.0.6" + "@types/json-schema": "npm:^7.0.15" + ajv: "npm:^6.12.4" + chalk: "npm:^4.0.0" + cross-spawn: "npm:^7.0.6" + debug: "npm:^4.3.2" + escape-string-regexp: "npm:^4.0.0" + eslint-scope: "npm:^8.2.0" + eslint-visitor-keys: "npm:^4.2.0" + espree: "npm:^10.3.0" + esquery: "npm:^1.5.0" + esutils: "npm:^2.0.2" + fast-deep-equal: "npm:^3.1.3" + file-entry-cache: "npm:^8.0.0" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + ignore: "npm:^5.2.0" + imurmurhash: "npm:^0.1.4" + is-glob: "npm:^4.0.0" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + lodash.merge: "npm:^4.6.2" + minimatch: "npm:^3.1.2" + natural-compare: "npm:^1.4.0" + optionator: "npm:^0.9.3" + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true + bin: + eslint: bin/eslint.js + checksum: 10c0/558edb25b440cd51825d66fed3e84f1081bd6f4cb2cf994e60ece4c5978fa0583e88b75faf187c1fc21688c4ff7072f12bf5f6d1be1e09a4d6af78cff39dc520 + languageName: node + linkType: hard + "eslint@npm:~8.56.0": version: 8.56.0 resolution: "eslint@npm:8.56.0" @@ -8082,6 +8265,17 @@ __metadata: languageName: node linkType: hard +"espree@npm:^10.0.1, espree@npm:^10.3.0": + version: 10.3.0 + resolution: "espree@npm:10.3.0" + dependencies: + acorn: "npm:^8.14.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10c0/272beeaca70d0a1a047d61baff64db04664a33d7cfb5d144f84bc8a5c6194c6c8ebe9cc594093ca53add88baa23e59b01e69e8a0160ab32eac570482e165c462 + languageName: node + linkType: hard + "espree@npm:^9.6.0, espree@npm:^9.6.1": version: 9.6.1 resolution: "espree@npm:9.6.1" @@ -8112,6 +8306,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.5.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -8331,6 +8534,15 @@ __metadata: languageName: node linkType: hard +"file-entry-cache@npm:^8.0.0": + version: 8.0.0 + resolution: "file-entry-cache@npm:8.0.0" + dependencies: + flat-cache: "npm:^4.0.0" + checksum: 10c0/9e2b5938b1cd9b6d7e3612bdc533afd4ac17b2fc646569e9a8abbf2eb48e5eb8e316bc38815a3ef6a1b456f4107f0d0f055a614ca613e75db6bf9ff4d72c1638 + languageName: node + linkType: hard + "filelist@npm:^1.0.4": version: 1.0.4 resolution: "filelist@npm:1.0.4" @@ -8399,6 +8611,16 @@ __metadata: languageName: node linkType: hard +"flat-cache@npm:^4.0.0": + version: 4.0.1 + resolution: "flat-cache@npm:4.0.1" + dependencies: + flatted: "npm:^3.2.9" + keyv: "npm:^4.5.4" + checksum: 10c0/2c59d93e9faa2523e4fda6b4ada749bed432cfa28c8e251f33b25795e426a1c6dbada777afb1f74fcfff33934fdbdea921ee738fcc33e71adc9d6eca984a1cfc + languageName: node + linkType: hard + "flat@npm:^5.0.2": version: 5.0.2 resolution: "flat@npm:5.0.2" @@ -8415,6 +8637,13 @@ __metadata: languageName: node linkType: hard +"flatted@npm:^3.2.9": + version: 3.3.3 + resolution: "flatted@npm:3.3.3" + checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538 + languageName: node + linkType: hard + "flush-promises@npm:^1.0.2, flush-promises@npm:~1.0.2": version: 1.0.2 resolution: "flush-promises@npm:1.0.2" @@ -8954,6 +9183,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^14.0.0": + version: 14.0.0 + resolution: "globals@npm:14.0.0" + checksum: 10c0/b96ff42620c9231ad468d4c58ff42afee7777ee1c963013ff8aabe095a451d0ceeb8dcd8ef4cbd64d2538cef45f787a78ba3a9574f4a634438963e334471302d + languageName: node + linkType: hard + "globalthis@npm:^1.0.3": version: 1.0.3 resolution: "globalthis@npm:1.0.3" @@ -10417,6 +10653,13 @@ __metadata: languageName: node linkType: hard +"json-buffer@npm:3.0.1": + version: 3.0.1 + resolution: "json-buffer@npm:3.0.1" + checksum: 10c0/0d1c91569d9588e7eef2b49b59851f297f3ab93c7b35c7c221e288099322be6b562767d11e4821da500f3219542b9afd2e54c5dc573107c1126ed1080f8e96d7 + languageName: node + linkType: hard + "json-parse-better-errors@npm:^1.0.1": version: 1.0.2 resolution: "json-parse-better-errors@npm:1.0.2" @@ -10537,6 +10780,15 @@ __metadata: languageName: node linkType: hard +"keyv@npm:^4.5.4": + version: 4.5.4 + resolution: "keyv@npm:4.5.4" + dependencies: + json-buffer: "npm:3.0.1" + checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e + languageName: node + linkType: hard + "kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": version: 6.0.3 resolution: "kind-of@npm:6.0.3" @@ -13597,6 +13849,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.5.2": + version: 3.5.2 + resolution: "prettier@npm:3.5.2" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/d7b597ed33f39c32ace675896ad187f06a3e48dc8a1e80051b5c5f0dae3586d53981704b8fda5ac3b080e6c2e0e197d239131b953702674f044351621ca5e1ac + languageName: node + linkType: hard + "prettier@npm:~3.3.2": version: 3.3.2 resolution: "prettier@npm:3.3.2" From f5448f4eaaeb6084646538ddf3e502195a638f79 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 13:06:42 +0100 Subject: [PATCH 16/60] feat(browser-sdk,react-sdk): extend the web SDKs to support the new `config` option. (#285) This PR introduces the `config`, which is an user-supplied JSON value that matches a given feature context (similar to targeting). The resolved configuration variant is then passed down to the client SDKs for use. --- .vscode/settings.json | 4 +- package.json | 1 + packages/browser-sdk/README.md | 47 ++++- packages/browser-sdk/package.json | 2 +- packages/browser-sdk/src/client.ts | 91 +++++--- .../browser-sdk/src/feature/featureCache.ts | 11 +- packages/browser-sdk/src/feature/features.ts | 100 ++++++--- packages/browser-sdk/src/index.ts | 1 + packages/browser-sdk/test/client.test.ts | 2 +- packages/browser-sdk/test/features.test.ts | 63 ++++-- packages/browser-sdk/test/mocks/handlers.ts | 66 +++++- packages/browser-sdk/test/usage.test.ts | 198 ++++++++++++------ packages/node-sdk/src/client.ts | 3 + packages/node-sdk/src/types.ts | 5 + packages/node-sdk/test/client.test.ts | 4 + packages/react-sdk/README.md | 74 ++++++- packages/react-sdk/package.json | 4 +- packages/react-sdk/src/index.tsx | 78 +++++-- packages/react-sdk/test/usage.test.tsx | 28 +++ yarn.lock | 16 +- 20 files changed, 608 insertions(+), 190 deletions(-) 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/package.json b/packages/browser-sdk/package.json index c32c6401..f88940c6 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.1", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 24399556..a3d34a19 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -286,17 +286,38 @@ const defaultConfig: Config = { }; /** - * Represents a feature. + * A remotely managed configuration value for a feature. + */ +export type FeatureRemoteConfig = + | { + /** + * The key of the matched configuration value. + */ + key: string; + + /** + * The optional user-supplied payload data. + */ + payload: any; + } + | { key: undefined; payload: undefined }; + +/** + * A feature. */ export interface Feature { /** - * Result of feature flag evaluation + * Result of feature flag evaluation. */ isEnabled: boolean; + /* + * Optional user-defined configuration. + */ + config: FeatureRemoteConfig; + /** - * Function to send analytics events for this feature - * + * Function to send analytics events for this feature. */ track: () => Promise; @@ -322,17 +343,18 @@ function shouldShowToolbar(opts: InitOptions) { * BucketClient lets you interact with the Bucket API. */ export class BucketClient { - private publishableKey: string; - private context: BucketContext; + 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. */ @@ -474,7 +496,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 +518,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 +537,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 +546,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 +575,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 = @@ -647,35 +672,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(); } /** - * 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 { 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, 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/index.ts b/packages/browser-sdk/src/index.ts index 7b1186cb..2b4e7bf8 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -5,6 +5,7 @@ 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..9213a939 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -29,8 +29,10 @@ function featuresClientFactory() { const httpClient = new HttpClient("pk", { baseUrl: "https://front.bucket.co", }); + vi.spyOn(httpClient, "get"); vi.spyOn(httpClient, "post"); + return { cache, httpClient, @@ -58,7 +60,7 @@ function featuresClientFactory() { }; } -describe("FeaturesClient unit tests", () => { +describe("FeaturesClient", () => { test("fetches features", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); const featuresClient = newFeaturesClient(); @@ -73,8 +75,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 +119,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 +177,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 +354,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/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index ba55d857..49c3308c 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -910,6 +910,9 @@ export class BucketClient { return isEnabled; }, + get config() { + return undefined; + }, key, track: async () => { if (typeof options.user?.id === "undefined") { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 25b8b8b2..d2104832 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -94,6 +94,11 @@ export interface Feature { */ isEnabled: boolean; + /** + * Optional user-defined configuration if the feature is enabled. + */ + config: any; + /** * Track feature usage in Bucket. */ diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index ed6fac69..201d4879 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1698,11 +1698,13 @@ describe("BucketClient", () => { key: "feature1", isEnabled: true, track: expect.any(Function), + config: undefined, }, feature2: { key: "feature2", isEnabled: false, track: expect.any(Function), + config: undefined, }, }); @@ -1720,11 +1722,13 @@ describe("BucketClient", () => { key: "feature1", isEnabled: false, track: expect.any(Function), + config: undefined, }, feature2: { key: "feature2", isEnabled: true, track: expect.any(Function), + config: undefined, }, }); }); 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() { ; - * } + * return ; * } * ``` */ -export function useFeature(key: FeatureKey) { +export function useFeature(key: TKey): Feature { const { features: { features, isLoading }, client, } = useContext(ProviderContext); const track = () => client?.track(key); - const requestFeedback = ( - opts: Omit, - ) => client?.requestFeedback({ ...opts, featureKey: key }); + const requestFeedback = (opts: RequestFeedbackOptions) => + client?.requestFeedback({ ...opts, featureKey: key }); if (isLoading) { return { isLoading, isEnabled: false, + config: { key: undefined, payload: undefined }, track, requestFeedback, }; @@ -207,22 +233,34 @@ export function useFeature(key: FeatureKey) { 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() { - client - ?.sendCheckEvent({ - key, - value: enabled, - version: feature?.targetingVersion, - }) - .catch(() => { - // ignore - }); + sendCheckEvent(); return enabled; }, + get config() { + sendCheckEvent(); + return reducedConfig; + }, }; } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 97a705d8..e24afc04 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", @@ -221,6 +226,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 +241,7 @@ describe("useFeature", () => { await waitFor(() => { expect(result.current).toStrictEqual({ + config: { key: undefined, payload: undefined }, isEnabled: false, isLoading: false, track: expect.any(Function), @@ -244,6 +251,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/yarn.lock b/yarn.lock index 0811d8ae..452b70fc 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.1, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -1042,7 +1030,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.1" "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@testing-library/react": "npm:^15.0.7" From 6d2b7e7397fd36fcaaef1f8c30c7e4250f386f36 Mon Sep 17 00:00:00 2001 From: Lasse Boisen Andersen Date: Wed, 29 Jan 2025 16:39:17 +0100 Subject: [PATCH 17/60] Forward `toolbar` config in react-sdk (#299) Was missing in the previous alpha release, which meant that the value of the `toolbar` prop wasn't correctly configured in the browser SDK. --- packages/react-sdk/src/index.tsx | 1 + packages/react-sdk/test/usage.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index d31a76f2..835a094e 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -142,6 +142,7 @@ export function BucketProvider({ logger: config.debug ? console : undefined, sdkVersion: SDK_VERSION, featureList, + toolbar: config.toolbar, }); clientRef.current = client; diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index e24afc04..d53804c3 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -192,6 +192,7 @@ describe("", () => { logger: undefined, sseBaseUrl: "https://test.com", sseHost: undefined, + toolbar: undefined, enableTracking: false, feedback: undefined, featureList: undefined, From 74c77e57155d6ab38bbc606078f554fc3d8e7fa0 Mon Sep 17 00:00:00 2001 From: Lasse Boisen Andersen Date: Wed, 29 Jan 2025 16:57:55 +0100 Subject: [PATCH 18/60] Bump react + browser SDK to v3.0.0-alpha.2 (#300) --- packages/browser-sdk/package.json | 2 +- packages/react-sdk/package.json | 4 ++-- yarn.lock | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index f88940c6..734debca 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.1", + "version": "3.0.0-alpha.2", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 922573a2..ccd31aaa 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/react-sdk", - "version": "3.0.0-alpha.1", + "version": "3.0.0-alpha.2", "license": "MIT", "repository": { "type": "git", @@ -34,7 +34,7 @@ } }, "dependencies": { - "@bucketco/browser-sdk": "3.0.0-alpha.1", + "@bucketco/browser-sdk": "3.0.0-alpha.2", "canonical-json": "^0.0.4", "rollup": "^4.2.0" }, diff --git a/yarn.lock b/yarn.lock index 452b70fc..44bf0646 100644 --- a/yarn.lock +++ b/yarn.lock @@ -894,7 +894,7 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:3.0.0-alpha.1, @bucketco/browser-sdk@workspace:packages/browser-sdk": +"@bucketco/browser-sdk@npm:3.0.0-alpha.2, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -1030,7 +1030,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.1" + "@bucketco/browser-sdk": "npm:3.0.0-alpha.2" "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@testing-library/react": "npm:^15.0.7" From 87831e7dcfad9285daa5e09587b9aa16d1ea2005 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 29 Jan 2025 19:57:41 +0100 Subject: [PATCH 19/60] feat(node-sdk): support for remote configuration (#295) This PR adds support for remote configuration to the node-sdk. --- packages/node-sdk/README.md | 95 ++++++- packages/node-sdk/example/app.ts | 10 +- packages/node-sdk/example/bucket.ts | 27 +- packages/node-sdk/example/bucketConfig.json | 4 +- packages/node-sdk/src/client.ts | 173 ++++++++---- packages/node-sdk/src/config.ts | 34 ++- packages/node-sdk/src/types.ts | 200 ++++++++++++-- packages/node-sdk/test/client.test.ts | 289 ++++++++++++++++++-- packages/node-sdk/test/config.test.ts | 22 +- packages/node-sdk/test/testConfig.json | 11 +- 10 files changed, 746 insertions(+), 119 deletions(-) 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 49c3308c..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,40 +942,47 @@ 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() { - return undefined; + sendCheckEvent(); + return simplifiedConfig as TypedFeatures[TKey]["config"]; }, key, track: async () => { @@ -949,6 +1020,7 @@ export class BucketClient { ...context, enableTracking: true, }; + checkContextWithTracking(contextWithTracking); const params = new URLSearchParams( @@ -968,6 +1040,7 @@ export class BucketClient { context, Object.values(res.features), ); + return Object.fromEntries( Object.entries(res.features).map(([featureKey, feature]) => { return [ @@ -1035,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); } @@ -1045,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 d2104832..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,10 +145,10 @@ export interface Feature { */ isEnabled: boolean; - /** - * Optional user-defined configuration if the feature is enabled. + /* + * Optional user-defined configuration. */ - config: any; + config: TConfig extends never ? EmptyFeatureRemoteConfig : TConfig; /** * Track feature usage in Bucket. @@ -105,6 +156,16 @@ export interface Feature { track(): Promise; } +type FullFeatureOverride = { + isEnabled: boolean; + config?: { + key: string; + payload: any; + }; +}; + +type FeatureOverride = FullFeatureOverride | boolean; + /** * Describes a collection of evaluated features. * @@ -123,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 */ -type FeatureAPIResponse = { +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 + */ +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 = { /** @@ -335,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. @@ -352,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 201d4879..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,22 +1891,34 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: true, + config: { + key: "config-1", + payload: { + something: "else", + }, + }, track: expect.any(Function), - config: undefined, }, feature2: { key: "feature2", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), - config: undefined, }, }); 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); @@ -1721,14 +1927,23 @@ describe("BucketClient", () => { feature1: { key: "feature1", isEnabled: false, + config: { key: undefined, payload: undefined }, track: expect.any(Function), - config: undefined, }, 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), - config: undefined, }, }); }); @@ -1749,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", @@ -1772,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), }, }); @@ -1827,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"], }, }, @@ -1845,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); @@ -2069,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), }, }); @@ -2099,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, From 9f9144d388e16adafd24558a98d546c1f8cce049 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Mon, 3 Feb 2025 13:53:52 +0100 Subject: [PATCH 20/60] chore(node-sdk): bump version (#302) --- packages/node-sdk/package.json | 2 +- yarn.lock | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index ed19dfab..4e4c8236 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.5.0", + "version": "1.6.0-alpha.0", "license": "MIT", "repository": { "type": "git", diff --git a/yarn.lock b/yarn.lock index 44bf0646..3b0ac3f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -958,7 +958,16 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/node-sdk@npm:>=1.4.2, @bucketco/node-sdk@workspace:packages/node-sdk": +"@bucketco/node-sdk@npm:>=1.4.2": + version: 1.5.0 + resolution: "@bucketco/node-sdk@npm:1.5.0" + dependencies: + "@bucketco/flag-evaluation": "npm:~0.1.0" + checksum: 10c0/63230400c0c0fa6ccf8708550bbcf583cc58bd18a2b99e19ec1dde43bce593c43136790ff3f0573f171c123c6a0555eebafcefdfa5cc71a2e706079fdb1ebe39 + languageName: node + linkType: hard + +"@bucketco/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@bucketco/node-sdk@workspace:packages/node-sdk" dependencies: From d659c565e50939a1a4b3e21cc707b30705ba9407 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Feb 2025 09:20:20 +0100 Subject: [PATCH 21/60] chore: allow publishing of non `main` branches` (#304) --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5eec38c5..43d9ceba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v* jobs: release: From f5da70bbf46e27c20d786a8f8e05c652107a0e94 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Tue, 4 Feb 2025 10:48:04 +0000 Subject: [PATCH 22/60] fix(node-sdk): update Feature type to support undefined config --- packages/node-sdk/package.json | 2 +- packages/node-sdk/src/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 4e4c8236..68a3b4f3 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.6.0-alpha.0", + "version": "1.6.0-alpha.1", "license": "MIT", "repository": { "type": "git", diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index e8637774..28e67598 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -133,7 +133,7 @@ export type FeatureRemoteConfig = * Describes a feature */ export interface Feature< - TConfig extends FeatureRemoteConfig | never = EmptyFeatureRemoteConfig, + TConfig extends FeatureRemoteConfig | undefined = EmptyFeatureRemoteConfig, > { /** * The key of the feature. @@ -148,7 +148,7 @@ export interface Feature< /* * Optional user-defined configuration. */ - config: TConfig extends never ? EmptyFeatureRemoteConfig : TConfig; + config: TConfig extends undefined ? EmptyFeatureRemoteConfig : TConfig; /** * Track feature usage in Bucket. From cf86bc2bf0b7c36540aa086d9f202f939d8939fa Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 5 Feb 2025 06:24:55 +0000 Subject: [PATCH 23/60] chore(node-sdk): bump version to 1.6.0-alpha.3 --- packages/node-sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 68a3b4f3..041bbc27 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.6.0-alpha.1", + "version": "1.6.0-alpha.3", "license": "MIT", "repository": { "type": "git", From 29b896cf85a408406e10753aa0fd78b4bc35573f Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 12 Feb 2025 14:07:51 +0100 Subject: [PATCH 24/60] feat(node-sdk): improved flag events (#307) This PR improves the handling of check/evaluate events in the age of `remote config`. Fixes some additional small nagging issues along the way. --- docs.sh | 43 +- packages/node-sdk/README.md | 320 +++++++++++--- .../docs/type-check-payload-failed.png | Bin 0 -> 69185 bytes packages/node-sdk/package.json | 2 +- packages/node-sdk/src/client.ts | 212 +++++++--- packages/node-sdk/src/types.ts | 30 +- packages/node-sdk/test/client.test.ts | 395 ++++++++++++++---- yarn.lock | 218 +--------- 8 files changed, 785 insertions(+), 435 deletions(-) create mode 100644 packages/node-sdk/docs/type-check-payload-failed.png diff --git a/docs.sh b/docs.sh index e67f36f5..d5ecc670 100755 --- a/docs.sh +++ b/docs.sh @@ -15,11 +15,46 @@ typedoc # We can fix this by removing the number at the end of the anchor. SEDCOMMAND='s/globals.md#(.*)-[0-9]+/globals.md#\1/g' -FILES=$(find dist/docs/@bucketco -name "globals.md") +# Find all markdown files including globals.md +FILES=$(find dist/docs/@bucketco -name "*.md") +echo "Processing markdown files..." for file in $FILES do - sed -r $SEDCOMMAND $file > $file.fixed - rm $file - mv $file.fixed $file + echo "Processing $file..." + + # Fix anchor links in globals.md files + if [[ "$file" == *"globals.md" ]]; then + sed -r "$SEDCOMMAND" "$file" > "$file.fixed" + rm "$file" + mv "$file.fixed" "$file" + fi + + # Create a temporary file for processing + tmp_file="${file}.tmp" + + # Process NOTE blocks - handle multi-line + awk ' + BEGIN { in_block = 0; content = ""; } + /^> \[!NOTE\]/ { in_block = 1; print "{% hint style=\"info\" %}"; next; } + /^> \[!TIP\]/ { in_block = 1; print "{% hint style=\"success\" %}"; next; } + /^> \[!IMPORTANT\]/ { in_block = 1; print "{% hint style=\"warning\" %}"; next; } + /^> \[!WARNING\]/ { in_block = 1; print "{% hint style=\"warning\" %}"; next; } + /^> \[!CAUTION\]/ { in_block = 1; print "{% hint style=\"danger\" %}"; next; } + in_block && /^>/ { + content = content substr($0, 3) "\n"; + next; + } + in_block && !/^>/ { + printf "%s", content; + print "{% endhint %}"; + in_block = 0; + content = ""; + } + !in_block { print; } + ' "$file" > "$tmp_file" + + mv "$tmp_file" "$file" done + +echo "Processing complete!" diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index b673c9f2..2738b958 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -20,12 +20,9 @@ To get started you need to obtain your secret key from the [environment settings](https://app.bucket.co/envs/current/settings/app-environments) in Bucket. -{% hint style="danger" %} -Secret keys are meant for use in server side SDKs only. -Secret keys offer the users the ability to obtain -information that is often sensitive and thus should not be used in -client-side applications. -{% endhint %} +> [!CAUTION] +> Secret keys are meant for use in server side SDKs only. Secret keys offer the users the ability to obtain +> information that is often sensitive and thus should not be used in client-side applications. Bucket will load settings through the various environment variables automatically (see [Configuring](#configuring) below). @@ -54,7 +51,8 @@ bucketClient.initialize().then({ Once the client is initialized, you can obtain features along with the `isEnabled` status to indicate whether the feature is targeted for this user/company: -_Note_: If `user.id` or `company.id` is not given, the whole `user` or `company` object is ignored. +> [!IMPORTANT] +> If `user.id` or `company.id` is not given, the whole `user` or `company` object is ignored. ```typescript // configure the client @@ -105,7 +103,7 @@ const bothEnabled = ## High performance feature targeting -The Bucket Node SDK contacts the Bucket servers when you call `initialize()` +The SDK contacts the Bucket servers when you call `initialize()` and downloads the features with their targeting rules. These rules are then matched against the user/company information you provide to `getFeatures()` (or through `bindClient(..).getFeatures()`). That means the @@ -113,6 +111,119 @@ 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. +### Batch Operations + +The SDK automatically batches operations like user/company updates and feature tracking events to minimize API calls. +The batch buffer is configurable through the client options: + +```typescript +const client = new BucketClient({ + batchOptions: { + maxSize: 100, // Maximum number of events to batch + intervalMs: 1000, // Flush interval in milliseconds + }, +}); +``` + +You can manually flush the batch buffer at any time: + +```typescript +await client.flush(); +``` + +> [!TIP] +> It's recommended to call `flush()` before your application shuts down to ensure all events are sent. + +### Rate Limiting + +The SDK includes automatic rate limiting for feature events to prevent overwhelming the API. Rate limiting is applied per +unique combination of feature key and context. The rate limiter window size is configurable: + +```typescript +const client = new BucketClient({ + rateLimiterOptions: { + windowSizeMs: 60000, // Rate limiting window size in milliseconds + }, +}); +``` + +### Caching + +Feature definitions are automatically cached and refreshed in the background. The cache behavior is configurable: + +```typescript +const client = new BucketClient({ + refetchInterval: 30000, // How often to refresh features (ms) + staleWarningInterval: 150000, // When to warn about stale features (ms) +}); +``` + +## Error Handling + +The SDK is designed to fail gracefully and never throw exceptions to the caller. Instead, it logs errors and provides +fallback behavior: + +1. **Feature Evaluation Failures**: + + ```typescript + const { isEnabled } = client.getFeature("my-feature"); + // If feature evaluation fails, isEnabled will be false + ``` + +2. **Network Errors**: + + ```typescript + // Network errors during tracking are logged but don't affect your application + const { track } = client.getFeature("my-feature"); + if (isEnabled) { + try { + await track(); + } catch (error) { + // The SDK already logged this error + // Your application can continue normally + } + } + ``` + +3. **Missing Context**: + + ```typescript + // The SDK tracks missing context fields but continues operation + const features = client.getFeatures({ + user: { id: "user123" }, + // Missing company context will be logged but won't cause errors + }); + ``` + +4. **Offline Mode**: + + ```typescript + // In offline mode, the SDK uses fallback features + const client = new BucketClient({ + offline: true, + fallbackFeatures: { + "my-feature": true, + }, + }); + ``` + +The SDK logs all errors with appropriate severity levels. You can customize logging by providing your own logger: + +```typescript +const client = new BucketClient({ + logger: { + debug: (msg) => console.debug(msg), + info: (msg) => console.info(msg), + warn: (msg) => console.warn(msg), + error: (msg, error) => { + console.error(msg, error); + // Send to your error tracking service + errorTracker.capture(error); + }, + }, +}); +``` + ### Remote config Similar to `isEnabled`, each feature has a `config` property. This configuration is managed from within Bucket. @@ -157,7 +268,7 @@ current working directory. | `featureOverrides` | Record | An object specifying feature overrides for testing or local development. See [example/app.test.ts](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/browser-sdk/example/app.test.ts) for how to use `featureOverrides` in tests. | BUCKET_FEATURES_ENABLED, BUCKET_FEATURES_DISABLED | | `configFile` | string | Load this config file from disk. Default: `bucketConfig.json` | BUCKET_CONFIG_FILE | -Note: BUCKET_FEATURES_ENABLED, BUCKET_FEATURES_DISABLED are comma separated lists of features which will be enabled or disabled respectively. +> [!NOTE] > `BUCKET_FEATURES_ENABLED` and `BUCKET_FEATURES_DISABLED` are comma separated lists of features which will be enabled or disabled respectively. `bucketConfig.json` example: @@ -169,11 +280,14 @@ Note: BUCKET_FEATURES_ENABLED, BUCKET_FEATURES_DISABLED are comma separated list "apiBaseUrl": "https://proxy.slick-demo.com", "featureOverrides": { "huddles": true, - "voiceChat": false, + "voiceChat": { "isEnabled": false }, "aiAssist": { - "key": "gpt-4.0", - "payload": { - "maxTokens": 50000 + "isEnabled": true, + "config": { + "key": "gpt-4.0", + "payload": { + "maxTokens": 50000 + } } } } @@ -201,69 +315,156 @@ import { BucketClient } from "@bucketco/node-sdk"; declare module "@bucketco/node-sdk" { interface Features { "show-todos": boolean; - "create-todos": { isEnabled: boolean }; + "create-todos": { + isEnabled: boolean; + config: { + key: string; + payload: { + minimumLength: number; + }; + }; + }; "delete-todos": { - isEnabled: boolean, - config: any + isEnabled: boolean; + config: { + key: string; + payload: { + requireConfirmation: boolean; + maxDeletionsPerDay: number; + }; + }; }; } } export const bucketClient = new BucketClient(); -bucketClient.initialize().then({ - console.log("Bucket initialized!") - bucketClient.getFeature("invalid-feature") // feature doesn't exist -}) +bucketClient.initialize().then(() => { + console.log("Bucket initialized!"); + + // TypeScript will catch this error: "invalid-feature" doesn't exist + bucketClient.getFeature("invalid-feature"); + + const { + isEnabled, + config: { payload }, + } = bucketClient.getFeature("create-todos"); +}); ``` -The following example show how to add strongly typed payloads when using remote configuration: +![Type check failed](docs/type-check-failed.png "Type check failed") ```typescript -import { BucketClient } from "@bucketco/node-sdk"; +bucketClient.initialize().then(() => { + // TypeScript will catch this error as well: "minLength" is not part of the payload. + if (isEnabled && todo.length > config.payload.minLength) { + // ... + } +}); +``` -type ConfirmationConfig = { - shouldShowConfirmation: boolean; -}; +![Config type check failed](docs/type-check-payload-failed.png "Remote config type check failed") -declare module "@bucketco/node-sdk" { - interface Features { +## Feature Overrides + +Feature overrides allow you to override feature flags and their configurations locally. This is particularly useful for development and testing. You can specify overrides in three ways: + +1. Through environment variables: + +```bash +BUCKET_FEATURES_ENABLED=feature1,feature2 +BUCKET_FEATURES_DISABLED=feature3,feature4 +``` + +1. Through `bucketConfig.json`: + +```json +{ + "featureOverrides": { "delete-todos": { - isEnabled: boolean; - config: { - key: string; - payload: ConfirmationConfig; - }; - }; + "isEnabled": true, + "config": { + "key": "dev-config", + "payload": { + "requireConfirmation": true, + "maxDeletionsPerDay": 5 + } + } + } } } +``` -export const bucketClient = new BucketClient(); +1. Programmatically through the client options: -function deleteTodo(todoId: string) { - // get the feature information - const { - isEnabled, - config: { payload: confirmationConfig }, - } = bucketClient.getFeature("delete-todos"); +```typescript +import { BucketClient, Context } from "@bucketco/node-sdk"; + +const featureOverrides = (context: Context) => ({ + "delete-todos": { + isEnabled: true, + config: { + key: "dev-config", + payload: { + requireConfirmation: true, + maxDeletionsPerDay: 5, + }, + }, + }, +}); - // check that feature is enabled for user - if (!isEnabled) { - return; - } +const client = new BucketClient({ + featureOverrides, +}); +``` - // 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 - } -} +## Remote Feature Evaluation + +In addition to local feature evaluation, Bucket supports remote evaluation using stored context. This is useful when you want to evaluate features using user/company attributes that were previously sent to Bucket: + +```typescript +// First, update user and company attributes +await client.updateUser("user123", { + attributes: { + role: "admin", + subscription: "premium", + }, +}); + +await client.updateCompany("company456", { + attributes: { + tier: "enterprise", + employees: 1000, + }, +}); + +// Later, evaluate features remotely using stored context +const features = await client.getFeaturesRemote("company456", "user123"); +// Or evaluate a single feature +const feature = await client.getFeatureRemote( + "create-todos", + "company456", + "user123", +); + +// You can also provide additional context +const featuresWithContext = await client.getFeaturesRemote( + "company456", + "user123", + { + other: { + location: "US", + platform: "mobile", + }, + }, +); ``` -![Type check failed](docs/type-check-failed.png "Type check failed") +Remote evaluation is particularly useful when: + +- You want to use the most up-to-date user/company attributes stored in Bucket +- You don't want to pass all context attributes with every evaluation +- You need to ensure consistent feature evaluation across different services ## Using with Express @@ -358,9 +559,10 @@ client.updateCompany("acme_inc", { const features = await client.getFeaturesRemote("acme_inc", "john_doe"); ``` -NOTE: User and company attribute updates are processed asynchronously, so there might -be a small delay between when attributes are updated and when they are available -for evaluation. +> [!IMPORTANT] +> User and company attribute updates are processed asynchronously, so there might +> be a small delay between when attributes are updated and when they are available +> for evaluation. ## Opting out of tracking @@ -389,7 +591,7 @@ Another way way to disable tracking without employing a bound client is to call or `getFeatures()` by supplying `enableTracking: false` in the arguments passed to these functions. -> [!NOTE] +> [!IMPORTANT] > Note, however, that calling `track()`, `updateCompany()` or `updateUser()` in the `BucketClient` > will still send tracking data. As such, it is always recommended to use `bindClient()` > when using this SDK. @@ -456,7 +658,7 @@ Some attributes are used by Bucket to improve the UI, and are recommended to provide for easier navigation: - `name` -- display name for `user`/`company`, -- `email` -- the email of the user. +- `email` -- the email of the user, - `avatar` -- the URL for `user`/`company` avatar image. Attributes cannot be nested (multiple levels) and must be either strings, diff --git a/packages/node-sdk/docs/type-check-payload-failed.png b/packages/node-sdk/docs/type-check-payload-failed.png new file mode 100644 index 0000000000000000000000000000000000000000..6f7a4860ae660a511090c824bb61db8607473bee GIT binary patch literal 69185 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 literal 0 HcmV?d00001 diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 041bbc27..69f1c233 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.6.0-alpha.3", + "version": "1.6.0-alpha.4", "license": "MIT", "repository": { "type": "git", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index dfe0278b..becb0b82 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -66,10 +66,13 @@ type BulkEvent = } | { type: "feature-flag-event"; - action: "check" | "evaluate"; + action: "check" | "evaluate" | "check-config" | "evaluate-config"; key: string; targetingVersion?: number; - evalResult: boolean; + evalResult: + | boolean + | { key: string; payload: any } + | { key: undefined; payload: undefined }; evalContext?: Record; evalRuleResults?: boolean[]; evalMissingFields?: string[]; @@ -206,7 +209,6 @@ export class BucketClient { typeof fallback === "object" && fallback.config ? { key: fallback.config.key, - default: true, payload: fallback.config.payload, } : undefined, @@ -477,6 +479,8 @@ export class BucketClient { isEnabled: feature?.isEnabled ?? false, targetingVersion: feature?.targetingVersion, config: feature?.config, + ruleEvaluationResults: feature?.ruleEvaluationResults, + missingContextFields: feature?.missingContextFields, }); } @@ -648,7 +652,10 @@ export class BucketClient { ok(typeof event === "object", "event must be an object"); ok( typeof event.action === "string" && - (event.action === "evaluate" || event.action === "check"), + (event.action === "evaluate" || + event.action === "evaluate-config" || + event.action === "check" || + event.action === "check-config"), "event must have an action", ); ok( @@ -661,7 +668,7 @@ export class BucketClient { "event must have a targeting version", ); ok( - typeof event.evalResult === "boolean", + typeof event.evalResult === "boolean" || isObject(event.evalResult), "event must have an evaluation result", ); ok( @@ -783,36 +790,60 @@ export class BucketClient { /** * Warns if any features have targeting rules that require context fields that are missing. * - * @param options - The options. + * @param context - The context. * @param features - The features to check. */ private warnMissingFeatureContextFields( - options: Context, - features: { key: string; missingContextFields?: string[] }[], + context: Context, + features: { + key: string; + missingContextFields?: string[]; + config?: { + key: string; + missingContextFields?: string[]; + }; + }[], ) { - features.forEach(({ key, missingContextFields }) => { - if (missingContextFields?.length) { + const report = features.reduce( + (acc, { config, ...feature }) => { if ( - !this._config.rateLimiter.isAllowed( + feature.missingContextFields?.length && + this._config.rateLimiter.isAllowed( hashObject({ - key, - missingContextFields, - options, + featureKey: feature.key, + missingContextFields: feature.missingContextFields, + context, }), ) ) { - return; + acc[feature.key] = feature.missingContextFields; + } + + if ( + config?.missingContextFields?.length && + this._config.rateLimiter.isAllowed( + hashObject({ + featureKey: feature.key, + configKey: config.key, + missingContextFields: config.missingContextFields, + context, + }), + ) + ) { + acc[`${feature.key}.config`] = config.missingContextFields; } - const missingFieldsStr = missingContextFields - .map((field) => `"${field}"`) - .join(", "); + return acc; + }, + {} as Record, + ); - this._config.logger?.warn( - `feature "${key}" has targeting rules that require the following context fields: ${missingFieldsStr}`, - ); - } - }); + if (Object.keys(report).length > 0) { + this._config.logger?.warn( + `feature/remote config targeting rules might not be correctly evaluated due to missing context fields.`, + report, + ); + } } private _getFeatures( @@ -872,6 +903,8 @@ export class BucketClient { acc[featureKey] = { ...variant.value, targetingVersion: feature.config.version, + ruleEvaluationResults: variant.ruleEvaluationResults, + missingContextFields: variant.missingContextFields, }; } } @@ -889,22 +922,50 @@ export class BucketClient { ); if (enableTracking) { - evaluated.forEach(async (res) => { - try { - await this.sendFeatureEvent({ - action: "evaluate", - key: res.featureKey, - targetingVersion: featureMap[res.featureKey].targeting.version, - evalResult: res.value ?? false, - evalContext: res.context, - evalRuleResults: res.ruleEvaluationResults, - evalMissingFields: res.missingContextFields, - }); - } catch (err) { - this._config.logger?.error( - `failed to send evaluate event for "${res.featureKey}"`, - err, + const promises = evaluated + .map((res) => { + const outPromises: Promise[] = []; + outPromises.push( + this.sendFeatureEvent({ + action: "evaluate", + key: res.featureKey, + targetingVersion: featureMap[res.featureKey].targeting.version, + evalResult: res.value ?? false, + evalContext: res.context, + evalRuleResults: res.ruleEvaluationResults, + evalMissingFields: res.missingContextFields, + }), ); + + const config = evaluatedConfigs[res.featureKey]; + if (config) { + outPromises.push( + this.sendFeatureEvent({ + action: "evaluate-config", + key: res.featureKey, + targetingVersion: config.targetingVersion, + evalResult: { key: config.key, payload: config.payload }, + evalContext: res.context, + evalRuleResults: config.ruleEvaluationResults, + evalMissingFields: config.missingContextFields, + }), + ); + } + + return outPromises; + }) + .flat(); + + void Promise.allSettled(promises).then((results) => { + const failed = results + .map((result) => + result.status === "rejected" ? result.reason : undefined, + ) + .filter(Boolean); + if (failed.length > 0) { + this._config.logger?.error(`failed to queue some evaluate events.`, { + errors: failed, + }); } }); } @@ -915,8 +976,9 @@ export class BucketClient { key: res.featureKey, isEnabled: res.value ?? false, config: evaluatedConfigs[res.featureKey], - targetingVersion: featureMap[res.featureKey].targeting.version, + ruleEvaluationResults: res.ruleEvaluationResults, missingContextFields: res.missingContextFields, + targetingVersion: featureMap[res.featureKey].targeting.version, }; return acc; }, @@ -947,53 +1009,69 @@ export class BucketClient { } private _wrapRawFeature( - options: { enableTracking: boolean } & Context, - { key, isEnabled, config, targetingVersion }: RawFeature, + { enableTracking, ...context }: { enableTracking: boolean } & Context, + { config, ...feature }: 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() { - sendCheckEvent(); - return isEnabled; + if (enableTracking) { + void client + .sendFeatureEvent({ + action: "check", + key: feature.key, + targetingVersion: feature.targetingVersion, + evalResult: feature.isEnabled, + evalContext: context, + evalRuleResults: feature.ruleEvaluationResults, + evalMissingFields: feature.missingContextFields, + }) + .catch((err) => { + client._config.logger?.error( + `failed to send check event for "${feature.key}": ${err}`, + err, + ); + }); + } + return feature.isEnabled; }, get config() { - sendCheckEvent(); + if (enableTracking) { + void client + .sendFeatureEvent({ + action: "check-config", + key: feature.key, + targetingVersion: config?.targetingVersion, + evalResult: simplifiedConfig, + evalContext: context, + evalRuleResults: config?.ruleEvaluationResults, + evalMissingFields: config?.missingContextFields, + }) + .catch((err) => { + client._config.logger?.error( + `failed to send check event for "${feature.key}": ${err}`, + err, + ); + }); + } return simplifiedConfig as TypedFeatures[TKey]["config"]; }, - key, + key: feature.key, track: async () => { - if (typeof options.user?.id === "undefined") { + if (typeof context.user?.id === "undefined") { this._config.logger?.warn("no user set, cannot track event"); return; } - if (options.enableTracking) { - await this.track(options.user.id, key, { - companyId: options.company?.id, + if (enableTracking) { + await this.track(context.user.id, feature.key, { + companyId: context.company?.id, }); } else { this._config.logger?.debug("tracking disabled, not tracking event"); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 28e67598..0fc2685a 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -22,7 +22,7 @@ export type FeatureEvent = { /** * The action that was performed. **/ - action: "evaluate" | "check"; + action: "evaluate" | "evaluate-config" | "check" | "check-config"; /** * The feature key. @@ -37,7 +37,10 @@ export type FeatureEvent = { /** * The result of targeting evaluation. **/ - evalResult: boolean; + evalResult: + | boolean + | { key: string; payload: any } + | { key: undefined; payload: undefined }; /** * The context that was used for evaluation. @@ -70,14 +73,19 @@ export type RawFeatureRemoteConfig = { targetingVersion?: number; /** - * Indicates if the config value is the default. + * The optional user-supplied payload data. */ - default: boolean; + payload: any; /** - * The optional user-supplied payload data. + * The rule results of the evaluation (optional). */ - payload: any; + ruleEvaluationResults?: boolean[]; + + /** + * The missing fields in the evaluation context (optional). + */ + missingContextFields?: string[]; }; /** @@ -104,6 +112,11 @@ export interface RawFeature { */ config?: RawFeatureRemoteConfig; + /** + * The rule results of the evaluation (optional). + */ + ruleEvaluationResults?: boolean[]; + /** * The missing fields in the evaluation context (optional). */ @@ -227,11 +240,6 @@ export type FeatureConfigVariant = { * The key of the variant. */ key: string; - - /** - * Indicates if the variant is the default variant. - */ - default: boolean; }; /** diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index bc96e128..03092632 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -240,7 +240,6 @@ describe("BucketClient", () => { key: "feature2", isEnabled: true, config: { - default: true, key: "config1", payload: { value: true }, }, @@ -1057,12 +1056,14 @@ describe("BucketClient", () => { user, other: otherContext, }; + // test that the feature is returned await client.initialize(); const feature = client.getFeature( { ...context, enableTracking: true }, "feature1", ); + await feature.track(); await client.flush(); @@ -1102,6 +1103,21 @@ describe("BucketClient", () => { evalRuleResults: [true], evalMissingFields: [], }, + { + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + targetingVersion: 1, + evalContext: context, + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + evalRuleResults: [true], + evalMissingFields: [], + }, { type: "feature-flag-event", action: "evaluate", @@ -1149,6 +1165,11 @@ describe("BucketClient", () => { action: "evaluate", key: "feature1", }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + }), expect.objectContaining({ type: "feature-flag-event", action: "evaluate", @@ -1157,9 +1178,12 @@ describe("BucketClient", () => { { type: "feature-flag-event", action: "check", - evalResult: true, - targetingVersion: 1, key: "feature1", + targetingVersion: 1, + evalResult: true, + evalContext: context, + evalRuleResults: [true], + evalMissingFields: [], }, ], ); @@ -1192,6 +1216,11 @@ describe("BucketClient", () => { action: "evaluate", key: "feature1", }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + }), expect.objectContaining({ type: "feature-flag-event", action: "evaluate", @@ -1199,10 +1228,18 @@ describe("BucketClient", () => { }), { type: "feature-flag-event", - action: "check", - evalResult: true, - targetingVersion: 1, + action: "check-config", key: "feature1", + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + targetingVersion: 1, + evalContext: context, + evalRuleResults: [true], + evalMissingFields: [], }, ], ); @@ -1214,6 +1251,7 @@ describe("BucketClient", () => { user, other: otherContext, }; + // test that the feature is returned await client.initialize(); const feature = client.getFeature(context, "unknown-feature"); @@ -1233,31 +1271,30 @@ describe("BucketClient", () => { expect.objectContaining({ type: "user", }), - { + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature1", - targetingVersion: 1, - evalContext: context, - evalResult: true, - evalRuleResults: [true], - evalMissingFields: [], - }, - { + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + }), + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature2", - targetingVersion: 2, - evalContext: context, - evalResult: false, - evalRuleResults: [false], - evalMissingFields: ["something"], - }, + }), { type: "feature-flag-event", action: "check", - evalResult: false, key: "unknown-feature", + targetingVersion: undefined, + evalContext: context, + evalResult: false, + evalRuleResults: undefined, + evalMissingFields: undefined, }, { type: "event", @@ -1381,6 +1418,25 @@ describe("BucketClient", () => { evalRuleResults: [true], evalMissingFields: [], }, + { + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + targetingVersion: 1, + evalContext: { + company, + user, + other: otherContext, + }, + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + evalRuleResults: [true], + evalMissingFields: [], + }, { type: "feature-flag-event", action: "evaluate", @@ -1395,19 +1451,69 @@ describe("BucketClient", () => { evalRuleResults: [false], evalMissingFields: ["something"], }, + { + action: "check-config", + evalContext: { + company, + user, + other: otherContext, + }, + evalResult: { + key: undefined, + payload: undefined, + }, + key: "feature2", + type: "feature-flag-event", + targetingVersion: undefined, + evalRuleResults: undefined, + evalMissingFields: undefined, + }, { action: "check", + evalContext: { + company, + user, + other: otherContext, + }, evalResult: false, key: "feature2", targetingVersion: 2, type: "feature-flag-event", + evalMissingFields: ["something"], + evalRuleResults: [false], + }, + { + action: "check-config", + evalContext: { + company, + user, + other: otherContext, + }, + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + key: "feature1", + targetingVersion: 1, + type: "feature-flag-event", + evalRuleResults: [true], + evalMissingFields: [], }, { action: "check", + evalContext: { + company, + user, + other: otherContext, + }, evalResult: true, key: "feature1", targetingVersion: 1, type: "feature-flag-event", + evalRuleResults: [true], + evalMissingFields: [], }, ], ); @@ -1424,9 +1530,10 @@ describe("BucketClient", () => { }); expect(logger.warn).toHaveBeenCalledWith( - expect.stringMatching( - 'feature "feature2" has targeting rules that require the following context fields: "something"', - ), + "feature/remote config targeting rules might not be correctly evaluated due to missing context fields.", + { + feature2: ["something"], + }, ); }); @@ -1477,44 +1584,62 @@ describe("BucketClient", () => { expectedHeaders, [ expect.objectContaining({ type: "user" }), - { + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature1", - targetingVersion: 1, evalContext: { user, }, - evalResult: true, - evalRuleResults: [true], - evalMissingFields: [], - }, - { + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + evalContext: { + user, + }, + }), + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature2", - targetingVersion: 2, evalContext: { user, }, - evalResult: false, - evalRuleResults: [false], - evalMissingFields: ["something"], - }, - { - action: "check", - evalResult: false, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", + evalContext: { + user, + }, key: "feature2", - targetingVersion: 2, + }), + expect.objectContaining({ type: "feature-flag-event", - }, - { action: "check", - evalResult: true, + evalContext: { + user, + }, + key: "feature2", + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", + evalContext: { + user, + }, key: "feature1", - targetingVersion: 1, + }), + expect.objectContaining({ type: "feature-flag-event", - }, + action: "check", + evalContext: { + user, + }, + key: "feature1", + }), ], ); }); @@ -1554,44 +1679,62 @@ describe("BucketClient", () => { expectedHeaders, [ expect.objectContaining({ type: "company" }), - { + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature1", - targetingVersion: 1, evalContext: { company, }, - evalResult: true, - evalRuleResults: [true], - evalMissingFields: [], - }, - { + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", + evalContext: { + company, + }, + }), + expect.objectContaining({ type: "feature-flag-event", action: "evaluate", key: "feature2", - targetingVersion: 2, evalContext: { company, }, - evalResult: false, - evalRuleResults: [false], - evalMissingFields: ["something"], - }, - { - action: "check", - evalResult: false, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", key: "feature2", - targetingVersion: 2, + evalContext: { + company, + }, + }), + expect.objectContaining({ type: "feature-flag-event", - }, - { action: "check", - evalResult: true, + key: "feature2", + evalContext: { + company, + }, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", key: "feature1", - targetingVersion: 1, + evalContext: { + company, + }, + }), + expect.objectContaining({ type: "feature-flag-event", - }, + action: "check", + key: "feature1", + evalContext: { + company, + }, + }), ], ); }); @@ -1652,6 +1795,23 @@ describe("BucketClient", () => { evalRuleResults: [true], evalMissingFields: [], }, + { + type: "feature-flag-event", + action: "evaluate-config", + evalContext: { + other: otherContext, + }, + evalResult: { + key: "config-1", + payload: { + something: "else", + }, + }, + key: "feature1", + targetingVersion: 1, + evalMissingFields: [], + evalRuleResults: [true], + }, { type: "feature-flag-event", action: "evaluate", @@ -1671,12 +1831,14 @@ describe("BucketClient", () => { it("should send `track` with user and company if provided", async () => { await client.initialize(); const feature1 = client.getFeature({ company, user }, "feature1"); + await client.flush(); await feature1.track(); await client.flush(); - expect(httpClient.post).toHaveBeenCalledTimes(1); - expect(httpClient.post).toHaveBeenCalledWith( + expect(httpClient.post).toHaveBeenCalledTimes(2); + expect(httpClient.post).toHaveBeenNthCalledWith( + 1, BULK_ENDPOINT, expectedHeaders, [ @@ -1689,6 +1851,16 @@ describe("BucketClient", () => { expect.objectContaining({ type: "feature-flag-event", action: "evaluate", + key: "feature1", + evalContext: { + company, + user, + }, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", evalContext: { company, user, @@ -1697,7 +1869,15 @@ describe("BucketClient", () => { expect.objectContaining({ type: "feature-flag-event", action: "evaluate", + key: "feature2", }), + ], + ); + expect(httpClient.post).toHaveBeenNthCalledWith( + 2, + BULK_ENDPOINT, + expectedHeaders, + [ { companyId: "company123", event: "feature1", @@ -1712,11 +1892,13 @@ describe("BucketClient", () => { await client.initialize(); const feature = client.getFeature({ user }, "feature1"); + await client.flush(); await feature.track(); await client.flush(); - expect(httpClient.post).toHaveBeenCalledTimes(1); - expect(httpClient.post).toHaveBeenCalledWith( + expect(httpClient.post).toHaveBeenCalledTimes(2); + expect(httpClient.post).toHaveBeenNthCalledWith( + 1, BULK_ENDPOINT, expectedHeaders, [ @@ -1726,14 +1908,31 @@ describe("BucketClient", () => { expect.objectContaining({ type: "feature-flag-event", action: "evaluate", + key: "feature1", + evalContext: { + user, + }, + }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + key: "feature1", evalContext: { user, }, }), expect.objectContaining({ type: "feature-flag-event", + key: "feature2", action: "evaluate", }), + ], + ); + expect(httpClient.post).toHaveBeenNthCalledWith( + 2, + BULK_ENDPOINT, + expectedHeaders, + [ { event: "feature1", type: "event", @@ -1766,6 +1965,13 @@ describe("BucketClient", () => { company, }, }), + expect.objectContaining({ + type: "feature-flag-event", + action: "evaluate-config", + evalContext: { + company, + }, + }), expect.objectContaining({ type: "feature-flag-event", action: "evaluate", @@ -1806,12 +2012,17 @@ describe("BucketClient", () => { expectedHeaders, [ expect.objectContaining({ type: "user" }), - { + expect.objectContaining({ + type: "feature-flag-event", + action: "check-config", + key: "key", + }), + expect.objectContaining({ type: "feature-flag-event", action: "check", key: "key", evalResult: true, - }, + }), ], ); }); @@ -1969,13 +2180,20 @@ describe("BucketClient", () => { version: 3, default: true, payload: { something: "else" }, + missingContextFields: ["funny"], }, + missingContextFields: ["something", "funny"], }, feature2: { key: "feature2", targetingVersion: 2, isEnabled: false, - missingContextFields: ["something"], + missingContextFields: ["another"], + }, + feature3: { + key: "feature3", + targetingVersion: 5, + isEnabled: true, }, }, }, @@ -2009,6 +2227,12 @@ describe("BucketClient", () => { config: { key: undefined, payload: undefined }, track: expect.any(Function), }, + feature3: { + key: "feature3", + isEnabled: true, + config: { key: undefined, payload: undefined }, + track: expect.any(Function), + }, }); expect(httpClient.get).toHaveBeenCalledTimes(1); @@ -2033,7 +2257,12 @@ describe("BucketClient", () => { it("should warn if missing context fields", async () => { await client.getFeaturesRemote(); expect(logger.warn).toHaveBeenCalledWith( - 'feature "feature2" has targeting rules that require the following context fields: "something"', + "feature/remote config targeting rules might not be correctly evaluated due to missing context fields.", + { + feature1: ["something", "funny"], + "feature1.config": ["funny"], + feature2: ["another"], + }, ); }); }); @@ -2058,6 +2287,7 @@ describe("BucketClient", () => { version: 3, default: true, payload: { something: "else" }, + missingContextFields: ["two"], }, missingContextFields: ["one", "two"], }, @@ -2107,7 +2337,11 @@ describe("BucketClient", () => { it("should warn if missing context fields", async () => { await client.getFeatureRemote("feature1"); expect(logger.warn).toHaveBeenCalledWith( - 'feature "feature1" has targeting rules that require the following context fields: "one", "two"', + "feature/remote config targeting rules might not be correctly evaluated due to missing context fields.", + { + feature1: ["one", "two"], + "feature1.config": ["two"], + }, ); }); }); @@ -2284,6 +2518,13 @@ describe("BoundBucketClient", () => { key: "feature1", targetingVersion: 1, isEnabled: true, + config: { + key: "config-1", + version: 3, + default: true, + payload: { something: "else" }, + missingContextFields: ["else"], + }, }, feature2: { key: "feature2", @@ -2309,7 +2550,7 @@ describe("BoundBucketClient", () => { feature1: { key: "feature1", isEnabled: true, - config: { key: undefined, payload: undefined }, + config: { key: "config-1", payload: { something: "else" } }, track: expect.any(Function), }, feature2: { @@ -2340,7 +2581,7 @@ describe("BoundBucketClient", () => { expect(result).toStrictEqual({ key: "feature1", isEnabled: true, - config: { key: undefined, payload: undefined }, + config: { key: "config-1", payload: { something: "else" } }, track: expect.any(Function), }); diff --git a/yarn.lock b/yarn.lock index 3b0ac3f4..b05fd49b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -894,7 +894,7 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:3.0.0-alpha.2, @bucketco/browser-sdk@workspace:packages/browser-sdk": +"@bucketco/browser-sdk@npm:2.5.2, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -1039,7 +1039,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.2" + "@bucketco/browser-sdk": "npm:2.5.2" "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@testing-library/react": "npm:^15.0.7" @@ -1055,7 +1055,6 @@ __metadata: prettier: "npm:^3.3.3" react: "npm:*" react-dom: "npm:*" - rollup: "npm:^4.2.0" rollup-preserve-directives: "npm:^1.1.2" ts-node: "npm:^10.9.2" typescript: "npm:^5.7.3" @@ -2792,13 +2791,6 @@ __metadata: languageName: node linkType: hard -"@openfeature/core@npm:1.3.0": - version: 1.3.0 - resolution: "@openfeature/core@npm:1.3.0" - checksum: 10c0/48760b65d259d73d80ed5b3e03d5f4f604dfbe4a86561c0fb9c1b56d8a659ddead3c60260259ddca50d70c82d5dc181da5499d8a129b7bdcfeec0892e9865a0c - languageName: node - linkType: hard - "@openfeature/core@npm:1.5.0, @openfeature/core@npm:^1.5.0": version: 1.5.0 resolution: "@openfeature/core@npm:1.5.0" @@ -2972,13 +2964,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.32.0" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@rollup/rollup-android-arm64@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-android-arm64@npm:4.21.3" @@ -2993,13 +2978,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-android-arm64@npm:4.32.0" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-darwin-arm64@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-darwin-arm64@npm:4.21.3" @@ -3014,13 +2992,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-darwin-arm64@npm:4.32.0" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-darwin-x64@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-darwin-x64@npm:4.21.3" @@ -3035,27 +3006,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-darwin-x64@npm:4.32.0" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@rollup/rollup-freebsd-arm64@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.32.0" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - -"@rollup/rollup-freebsd-x64@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-freebsd-x64@npm:4.32.0" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@rollup/rollup-linux-arm-gnueabihf@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.21.3" @@ -3070,13 +3020,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.32.0" - conditions: os=linux & cpu=arm & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-arm-musleabihf@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.21.3" @@ -3091,13 +3034,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.32.0" - conditions: os=linux & cpu=arm & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-arm64-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.21.3" @@ -3112,13 +3048,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.32.0" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-arm64-musl@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.21.3" @@ -3133,20 +3062,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.32.0" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@rollup/rollup-linux-loongarch64-gnu@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.32.0" - conditions: os=linux & cpu=loong64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-powerpc64le-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.21.3" @@ -3161,13 +3076,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-powerpc64le-gnu@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.32.0" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-riscv64-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.21.3" @@ -3182,13 +3090,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.32.0" - conditions: os=linux & cpu=riscv64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-s390x-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.21.3" @@ -3203,13 +3104,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.32.0" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-x64-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.21.3" @@ -3224,13 +3118,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.32.0" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-x64-musl@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-x64-musl@npm:4.21.3" @@ -3245,13 +3132,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.32.0" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-win32-arm64-msvc@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.21.3" @@ -3266,13 +3146,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.32.0" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-win32-ia32-msvc@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.21.3" @@ -3287,13 +3160,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.32.0" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@rollup/rollup-win32-x64-msvc@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.21.3" @@ -3308,13 +3174,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.32.0": - version: 4.32.0 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.32.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@rushstack/eslint-patch@npm:^1.3.3": version: 1.10.3 resolution: "@rushstack/eslint-patch@npm:1.10.3" @@ -11137,7 +10996,6 @@ __metadata: resolution: "nextjs-openfeature-example@workspace:packages/openfeature-browser-provider/example" dependencies: "@bucketco/react-sdk": "workspace:^" - "@openfeature/core": "npm:1.3.0" "@openfeature/react-sdk": "npm:^0.4.5" "@openfeature/web-sdk": "npm:^1.2.3" "@types/node": "npm:^22.12.0" @@ -13647,78 +13505,6 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.2.0": - version: 4.32.0 - resolution: "rollup@npm:4.32.0" - dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.32.0" - "@rollup/rollup-android-arm64": "npm:4.32.0" - "@rollup/rollup-darwin-arm64": "npm:4.32.0" - "@rollup/rollup-darwin-x64": "npm:4.32.0" - "@rollup/rollup-freebsd-arm64": "npm:4.32.0" - "@rollup/rollup-freebsd-x64": "npm:4.32.0" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.32.0" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.32.0" - "@rollup/rollup-linux-arm64-gnu": "npm:4.32.0" - "@rollup/rollup-linux-arm64-musl": "npm:4.32.0" - "@rollup/rollup-linux-loongarch64-gnu": "npm:4.32.0" - "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.32.0" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.32.0" - "@rollup/rollup-linux-s390x-gnu": "npm:4.32.0" - "@rollup/rollup-linux-x64-gnu": "npm:4.32.0" - "@rollup/rollup-linux-x64-musl": "npm:4.32.0" - "@rollup/rollup-win32-arm64-msvc": "npm:4.32.0" - "@rollup/rollup-win32-ia32-msvc": "npm:4.32.0" - "@rollup/rollup-win32-x64-msvc": "npm:4.32.0" - "@types/estree": "npm:1.0.6" - fsevents: "npm:~2.3.2" - dependenciesMeta: - "@rollup/rollup-android-arm-eabi": - optional: true - "@rollup/rollup-android-arm64": - optional: true - "@rollup/rollup-darwin-arm64": - optional: true - "@rollup/rollup-darwin-x64": - optional: true - "@rollup/rollup-freebsd-arm64": - optional: true - "@rollup/rollup-freebsd-x64": - optional: true - "@rollup/rollup-linux-arm-gnueabihf": - optional: true - "@rollup/rollup-linux-arm-musleabihf": - optional: true - "@rollup/rollup-linux-arm64-gnu": - optional: true - "@rollup/rollup-linux-arm64-musl": - optional: true - "@rollup/rollup-linux-loongarch64-gnu": - optional: true - "@rollup/rollup-linux-powerpc64le-gnu": - optional: true - "@rollup/rollup-linux-riscv64-gnu": - optional: true - "@rollup/rollup-linux-s390x-gnu": - optional: true - "@rollup/rollup-linux-x64-gnu": - optional: true - "@rollup/rollup-linux-x64-musl": - optional: true - "@rollup/rollup-win32-arm64-msvc": - optional: true - "@rollup/rollup-win32-ia32-msvc": - optional: true - "@rollup/rollup-win32-x64-msvc": - optional: true - fsevents: - optional: true - bin: - rollup: dist/bin/rollup - checksum: 10c0/3e365a57a366fec5af8ef68b366ddffbff7ecaf426a9ffe3e20bbc1d848cbbb0f384556097efd8e70dec4155d7b56d5808df7f95c75751974aeeac825604b58a - languageName: node - linkType: hard - "rollup@npm:^4.20.0": version: 4.21.3 resolution: "rollup@npm:4.21.3" From b8710e051e797f3ffc750b750caf3e451708a247 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 3 Feb 2025 21:21:14 +0100 Subject: [PATCH 25/60] fix(node-sdk): ensure timers don't keep process alive (#303) Unable to write a test for this, but tested manually. --- packages/node-sdk/src/batch-buffer.ts | 2 +- packages/node-sdk/src/cache.ts | 2 +- packages/node-sdk/src/rate-limiter.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-sdk/src/batch-buffer.ts b/packages/node-sdk/src/batch-buffer.ts index 9f9039f5..2407bc9d 100644 --- a/packages/node-sdk/src/batch-buffer.ts +++ b/packages/node-sdk/src/batch-buffer.ts @@ -54,7 +54,7 @@ export default class BatchBuffer { if (this.buffer.length >= this.maxSize) { await this.flush(); } else if (!this.timer) { - this.timer = setTimeout(() => this.flush(), this.intervalMs); + this.timer = setTimeout(() => this.flush(), this.intervalMs).unref(); } } diff --git a/packages/node-sdk/src/cache.ts b/packages/node-sdk/src/cache.ts index 0d1bea9a..59800b23 100644 --- a/packages/node-sdk/src/cache.ts +++ b/packages/node-sdk/src/cache.ts @@ -43,7 +43,7 @@ export default function cache( logger?.error("failed to update cached value", e); } finally { refreshPromise = undefined; - timeoutId = setTimeout(update, ttl); + timeoutId = setTimeout(update, ttl).unref(); } }; diff --git a/packages/node-sdk/src/rate-limiter.ts b/packages/node-sdk/src/rate-limiter.ts index 0fba61ab..a532708b 100644 --- a/packages/node-sdk/src/rate-limiter.ts +++ b/packages/node-sdk/src/rate-limiter.ts @@ -43,7 +43,7 @@ export function newRateLimiter(windowSizeMs: number) { function isAllowed(key: string): boolean { clearIntervalId = - clearIntervalId || setInterval(() => clear(false), windowSizeMs); + clearIntervalId || setInterval(() => clear(false), windowSizeMs).unref(); const now = Date.now(); From 490e6e0f4c73e8bffbefce3d54b65efc0d6fe044 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Sun, 9 Feb 2025 20:23:57 +0100 Subject: [PATCH 26/60] feat(node-sdk): allow passing `meta` to `getFeature(s)` (#312) This PR adds the ability to pass the `meta` to `getFeature(s)` and the bound client. --- packages/node-sdk/src/client.ts | 238 +++++++++++++++++++------- packages/node-sdk/src/types.ts | 6 + packages/node-sdk/test/client.test.ts | 28 +-- 3 files changed, 194 insertions(+), 78 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index becb0b82..a89ebdbd 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -89,6 +89,16 @@ type BulkEvent = /** * The SDK client. * + * @remarks + * This is the main class for interacting with Bucket. + * It is used to update user and company contexts, track events, and evaluate feature flags. + * + * @example + * ```ts + * const client = new BucketClient({ + * secretKey: "your-secret-key", + * }); + * ``` **/ export class BucketClient { private _config: { @@ -119,6 +129,18 @@ export class BucketClient { * See README for configuration options. * * @param options - The options for the client or an existing client to clone. + * @param options.secretKey - The secret key to use for the client. + * @param options.apiBaseUrl - The base URL to send requests to (optional). + * @param options.logger - The logger to use for logging (optional). + * @param options.httpClient - The HTTP client to use for sending requests (optional). + * @param options.logLevel - The log level to use for logging (optional). + * @param options.offline - Whether to run in offline mode (optional). + * @param options.fallbackFeatures - The fallback features to use if the feature is not found (optional). + * @param options.batchOptions - The options for the batch buffer (optional). + * @param options.featureOverrides - The feature overrides to use for the client (optional). + * @param options.configFile - The path to the config file (optional). + + * * @throws An error if the options are invalid. **/ constructor(options: ClientOptions = {}) { @@ -258,6 +280,15 @@ export class BucketClient { return this._config.logger; } + /** + * Sets the feature overrides. + * + * @param overrides - The feature overrides. + * + * @remarks + * The feature overrides are used to override the feature definitions. + * This is useful for testing or development. + **/ set featureOverrides(overrides: FeatureOverridesFn) { this._config.featureOverrides = overrides; } @@ -267,8 +298,16 @@ export class BucketClient { * set to be used in subsequent calls. * For example, for evaluating feature targeting or tracking events. * + * @param context - The context to bind the client to. + * @param context.enableTracking - Whether to enable tracking for the context. + * @param context.user - The user context. + * @param context.company - The company context. + * @param context.other - The other context. + * * @returns A new client bound with the arguments given. + * * @throws An error if the user/company is given but their ID is not a string. + * * @remarks * The `updateUser` / `updateCompany` methods will automatically be called when * the user/company is set respectively. @@ -283,36 +322,37 @@ export class BucketClient { /** * Updates the associated user in Bucket. * - * @param opts.attributes - The additional attributes of the company (optional). - * @param opts.meta - The meta context associated with tracking (optional). + * @param userId - The userId of the user to update. + * @param options - The options for the user. + * @param options.attributes - The additional attributes of the user (optional). + * @param options.meta - The meta context associated with tracking (optional). * * @throws An error if the company is not set or the options are invalid. * @remarks * The company must be set using `withCompany` before calling this method. * If the user is set, the company will be associated with the user. **/ - public async updateUser(userId: IdType, opts?: TrackOptions) { + public async updateUser(userId: IdType, options?: TrackOptions) { idOk(userId, "userId"); - ok(opts === undefined || isObject(opts), "opts must be an object"); + ok(options === undefined || isObject(options), "options must be an object"); ok( - opts?.attributes === undefined || isObject(opts.attributes), + options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); - ok( - opts?.meta === undefined || isObject(opts.meta), - "meta must be an object", - ); + checkMeta(options?.meta); if (this._config.offline) { return; } - if (this._config.rateLimiter.isAllowed(hashObject({ ...opts, userId }))) { + if ( + this._config.rateLimiter.isAllowed(hashObject({ ...options, userId })) + ) { await this._config.batchBuffer.add({ type: "user", userId, - attributes: opts?.attributes, - context: opts?.meta, + attributes: options?.attributes, + context: options?.meta, }); } } @@ -320,8 +360,11 @@ export class BucketClient { /** * Updates the associated company in Bucket. * - * @param opts.attributes - The additional attributes of the company (optional). - * @param opts.meta - The meta context associated with tracking (optional). + * @param companyId - The companyId of the company to update. + * @param options - The options for the company. + * @param options.attributes - The additional attributes of the company (optional). + * @param options.meta - The meta context associated with tracking (optional). + * @param options.userId - The userId of the user to associate with the company (optional). * * @throws An error if the company is not set or the options are invalid. * @remarks @@ -330,20 +373,18 @@ export class BucketClient { **/ public async updateCompany( companyId: IdType, - opts?: TrackOptions & { userId?: IdType }, + options?: TrackOptions & { userId?: IdType }, ) { idOk(companyId, "companyId"); - ok(opts === undefined || isObject(opts), "opts must be an object"); + ok(options === undefined || isObject(options), "options must be an object"); ok( - opts?.attributes === undefined || isObject(opts.attributes), + options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); - ok( - opts?.meta === undefined || isObject(opts.meta), - "meta must be an object", - ); - if (typeof opts?.userId !== "undefined") { - idOk(opts?.userId, "userId"); + checkMeta(options?.meta); + + if (typeof options?.userId !== "undefined") { + idOk(options?.userId, "userId"); } if (this._config.offline) { @@ -351,14 +392,14 @@ export class BucketClient { } if ( - this._config.rateLimiter.isAllowed(hashObject({ ...opts, companyId })) + this._config.rateLimiter.isAllowed(hashObject({ ...options, companyId })) ) { await this._config.batchBuffer.add({ type: "company", companyId, - userId: opts?.userId, - attributes: opts?.attributes, - context: opts?.meta, + userId: options?.userId, + attributes: options?.attributes, + context: options?.meta, }); } } @@ -380,21 +421,21 @@ export class BucketClient { public async track( userId: IdType, event: string, - opts?: TrackOptions & { companyId?: IdType }, + options?: TrackOptions & { companyId?: IdType }, ) { idOk(userId, "userId"); ok(typeof event === "string" && event.length > 0, "event must be a string"); - ok(opts === undefined || isObject(opts), "opts must be an object"); + ok(options === undefined || isObject(options), "options must be an object"); ok( - opts?.attributes === undefined || isObject(opts.attributes), + options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); ok( - opts?.meta === undefined || isObject(opts.meta), + options?.meta === undefined || isObject(options.meta), "meta must be an object", ); - if (opts?.companyId !== undefined) { - idOk(opts?.companyId, "companyId"); + if (options?.companyId !== undefined) { + idOk(options?.companyId, "companyId"); } if (this._config.offline) { @@ -404,18 +445,16 @@ export class BucketClient { await this._config.batchBuffer.add({ type: "event", event, - companyId: opts?.companyId, + companyId: options?.companyId, userId, - attributes: opts?.attributes, - context: opts?.meta, + attributes: options?.attributes, + context: options?.meta, }); } /** * Initializes the client by caching the features definitions. * - * @returns void - * * @remarks * Call this method before calling `getFeatures` to ensure the feature definitions are cached. * The client will ignore subsequent calls to this method. @@ -437,9 +476,17 @@ export class BucketClient { } /** - * Gets the evaluated feature for the current context which includes the user, company, and custom context. + * Gets the evaluated features for the current context which includes the user, company, and custom context. + * + * @param options - The options for the context. + * @param options.enableTracking - Whether to enable tracking for the context. + * @param options.meta - The meta context associated with the context. + * @param options.user - The user context. + * @param options.company - The company context. + * @param options.other - The other context. * * @returns The evaluated features. + * * @remarks * Call `initialize` before calling this method to ensure the feature definitions are cached, no features will be returned otherwise. **/ @@ -462,7 +509,9 @@ export class BucketClient { * Gets the evaluated feature for the current context which includes the user, company, and custom context. * Using the `isEnabled` property sends a `check` event to Bucket. * - * @returns The evaluated features. + * @param key - The key of the feature to get. + * @returns The evaluated feature. + * * @remarks * Call `initialize` before calling this method to ensure the feature definitions are cached, no features will be returned otherwise. **/ @@ -488,9 +537,10 @@ export class BucketClient { * Gets evaluated features with the usage of remote context. * This method triggers a network request every time it's called. * - * @param userId - * @param companyId - * @param additionalContext + * @param userId - The userId of the user to get the features for. + * @param companyId - The companyId of the company to get the features for. + * @param additionalContext - The additional context to get the features for. + * * @returns evaluated features */ public async getFeaturesRemote( @@ -510,10 +560,11 @@ export class BucketClient { * Gets evaluated feature with the usage of remote context. * This method triggers a network request every time it's called. * - * @param key - * @param userId - * @param companyId - * @param additionalContext + * @param key - The key of the feature to get. + * @param userId - The userId of the user to get the feature for. + * @param companyId - The companyId of the company to get the feature for. + * @param additionalContext - The additional context to get the feature for. + * * @returns evaluated feature */ public async getFeatureRemote( @@ -545,7 +596,9 @@ export class BucketClient { * * @param path - The path to send the request to. * @param body - The body of the request. + * * @returns A boolean indicating if the request was successful. + * * @throws An error if the path or body is invalid. **/ private async post(path: string, body: TBody) { @@ -582,6 +635,7 @@ export class BucketClient { * Sends a GET request to the specified path. * * @param path - The path to send the request to. + * * @returns The response from the server. * @throws An error if the path is invalid. **/ @@ -618,7 +672,9 @@ export class BucketClient { /** * Sends a batch of events to the Bucket API. + * * @param events - The events to send. + * * @throws An error if the send fails. **/ private async sendBulkEvents(events: BulkEvent[]) { @@ -642,6 +698,13 @@ export class BucketClient { * the current context. * * @param event - The event to send. + * @param event.action - The action to send. + * @param event.key - The key of the feature to send. + * @param event.targetingVersion - The targeting version of the feature to send. + * @param event.evalResult - The evaluation result of the feature to send. + * @param event.evalContext - The evaluation context of the feature to send. + * @param event.evalRuleResults - The evaluation rule results of the feature to send. + * @param event.evalMissingFields - The evaluation missing fields of the feature to send. * * @throws An error if the event is invalid. * @@ -724,9 +787,14 @@ export class BucketClient { * Updates the context in Bucket (if needed). * This method should be used before requesting feature flags or binding a client. * - * @param options + * @param options - The options for the context. + * @param options.enableTracking - Whether to enable tracking for the context. + * @param options.meta - The meta context associated with the context. + * @param options.user - The user context. + * @param options.company - The company context. + * @param options.other - The other context. */ - private async syncContext(options: { enableTracking: boolean } & Context) { + private async syncContext(options: ContextWithTracking) { if (!options.enableTracking) { this._config.logger?.debug( "tracking disabled, not updating user/company", @@ -741,7 +809,7 @@ export class BucketClient { promises.push( this.updateCompany(options.company.id, { attributes, - meta: { active: false }, + meta: options.meta, }), ); } @@ -751,7 +819,7 @@ export class BucketClient { promises.push( this.updateUser(options.user.id, { attributes, - meta: { active: false }, + meta: options.meta, }), ); } @@ -847,7 +915,7 @@ export class BucketClient { } private _getFeatures( - options: { enableTracking: boolean } & Context, + options: ContextWithTracking, ): Record { checkContextWithTracking(options); @@ -876,7 +944,7 @@ export class BucketClient { {} as Record, ); - const { enableTracking = true, ...context } = options; + const { enableTracking = true, meta: _, ...context } = options; const evaluated = featureDefinitions.map((feature) => evaluateFeatureRules({ @@ -1141,6 +1209,16 @@ export class BoundBucketClient { private readonly _client: BucketClient; private readonly _options: ContextWithTracking; + /** + * (Internal) Creates a new BoundBucketClient. Use `bindClient` to create a new client bound with a specific context. + * + * @param client - The `BucketClient` to use. + * @param options - The options for the client. + * @param options.enableTracking - Whether to enable tracking for the client. + * @param options.context - The context for the client. + * + * @internal + */ constructor( client: BucketClient, { enableTracking = true, ...context }: ContextWithTracking, @@ -1194,6 +1272,8 @@ export class BoundBucketClient { * Get a specific feature for the user/company/other context bound to this client. * Using the `isEnabled` property sends a `check` event to Bucket. * + * @param key - The key of the feature to get. + * * @returns Features for the given user/company and whether each one is enabled or not */ public getFeature( @@ -1208,18 +1288,19 @@ export class BoundBucketClient { * @returns Features for the given user/company and whether each one is enabled or not */ public async getFeaturesRemote() { - const { enableTracking: _, ...context } = this._options; + const { enableTracking: _, meta: __, ...context } = this._options; return await this._client.getFeaturesRemote(undefined, undefined, context); } /** * Get remotely evaluated feature for the user/company/other context bound to this client. * - * @param key + * @param key - The key of the feature to get. + * * @returns Feature for the given user/company and key and whether it's enabled or not */ public async getFeatureRemote(key: string) { - const { enableTracking: _, ...context } = this._options; + const { enableTracking: _, meta: __, ...context } = this._options; return await this._client.getFeatureRemote( key, undefined, @@ -1232,18 +1313,19 @@ export class BoundBucketClient { * Track an event in Bucket. * * @param event - The event to track. - * @param opts - * @param opts.attributes - The attributes of the event (optional). - * @param opts.meta - The meta context associated with tracking (optional). - * @param opts.companyId - Optional company ID for the event (optional). + * @param options - The options for the event. + * @param options.attributes - The attributes of the event (optional). + * @param options.meta - The meta context associated with tracking (optional). + * @param options.companyId - Optional company ID for the event (optional). * * @throws An error if the event is invalid or the options are invalid. */ public async track( event: string, - opts?: TrackOptions & { companyId?: string }, + options?: TrackOptions & { companyId?: string }, ) { - ok(opts === undefined || isObject(opts), "opts must be an object"); + ok(options === undefined || isObject(options), "options must be an object"); + checkMeta(options?.meta); const userId = this._options.user?.id; @@ -1262,9 +1344,9 @@ export class BoundBucketClient { await this._client.track( userId, event, - opts?.companyId - ? opts - : { ...opts, companyId: this._options.company?.id }, + options?.companyId + ? options + : { ...options, companyId: this._options.company?.id }, ); } @@ -1272,6 +1354,13 @@ export class BoundBucketClient { * Create a new client bound with the additional context. * Note: This performs a shallow merge for user/company/other individually. * + * @param context - The context to bind the client to. + * @param context.user - The user to bind the client to. + * @param context.company - The company to bind the client to. + * @param context.other - The other context to bind the client to. + * @param context.enableTracking - Whether to enable tracking for the client. + * @param context.meta - The meta context to bind the client to. + * * @returns new client bound with the additional context */ public bindClient({ @@ -1279,7 +1368,8 @@ export class BoundBucketClient { company, other, enableTracking, - }: Context & { enableTracking?: boolean }) { + meta, + }: ContextWithTracking) { // merge new context into existing const boundConfig = { ...this._options, @@ -1287,6 +1377,7 @@ export class BoundBucketClient { company: company ? { ...this._options.company, ...company } : undefined, other: { ...this._options.other, ...other }, enableTracking: enableTracking ?? this._options.enableTracking, + meta: meta ?? this._options.meta, }; return new BoundBucketClient(this._client, boundConfig); @@ -1300,6 +1391,19 @@ export class BoundBucketClient { } } +function checkMeta( + meta?: TrackingMeta, +): asserts meta is TrackingMeta | undefined { + ok( + typeof meta === "undefined" || isObject(meta), + "meta must be an object if given", + ); + ok( + meta?.active === undefined || typeof meta?.active === "boolean", + "meta.active must be a boolean if given", + ); +} + function checkContextWithTracking( context: ContextWithTracking, ): asserts context is ContextWithTracking & { enableTracking: boolean } { @@ -1328,4 +1432,6 @@ function checkContextWithTracking( typeof context.enableTracking === "boolean", "enableTracking must be a boolean", ); + + checkMeta(context.meta); } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 0fc2685a..1cb21f20 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -633,6 +633,12 @@ export interface ContextWithTracking extends Context { * If set to `false`, tracking will be disabled for the context. Default is `true`. */ enableTracking?: boolean; + + /** + * The meta context used to update the user or company when syncing is required during + * feature retrieval. + */ + meta?: TrackingMeta; } export const LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const; diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 03092632..696217cc 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -440,9 +440,7 @@ describe("BucketClient", () => { type: "user", userId: user.id, attributes: attributes, - context: { - active: false, - }, + context: undefined, }, ], ); @@ -451,7 +449,7 @@ describe("BucketClient", () => { }); it("should update company in Bucket when called", async () => { - client.bindClient({ company: context.company }); + client.bindClient({ company: context.company, meta: { active: true } }); await client.flush(); const { id: _, ...attributes } = context.company; @@ -465,7 +463,7 @@ describe("BucketClient", () => { companyId: company.id, attributes: attributes, context: { - active: false, + active: true, }, }, ], @@ -599,7 +597,7 @@ describe("BucketClient", () => { it("should throw an error if opts are not valid or the user is not set", async () => { await expect( client.updateUser(user.id, "bad_opts" as any), - ).rejects.toThrow("opts must be an object"); + ).rejects.toThrow("validation failed: options must be an object"); await expect( client.updateUser(user.id, { attributes: "bad_attributes" as any }), @@ -691,7 +689,7 @@ describe("BucketClient", () => { it("should throw an error if company is not valid", async () => { await expect( client.updateCompany(company.id, "bad_opts" as any), - ).rejects.toThrow("opts must be an object"); + ).rejects.toThrow("validation failed: options must be an object"); await expect( client.updateCompany(company.id, { @@ -848,7 +846,7 @@ describe("BucketClient", () => { await expect( boundClient.track(event.event, "bad_opts" as any), - ).rejects.toThrow("opts must be an object"); + ).rejects.toThrow("validation failed: options must be an object"); await expect( boundClient.track(event.event, { @@ -1060,7 +1058,13 @@ describe("BucketClient", () => { // test that the feature is returned await client.initialize(); const feature = client.getFeature( - { ...context, enableTracking: true }, + { + ...context, + meta: { + active: true, + }, + enableTracking: true, + }, "feature1", ); @@ -1078,7 +1082,7 @@ describe("BucketClient", () => { }, companyId: "company123", context: { - active: false, + active: true, }, type: "company", }, @@ -1088,7 +1092,7 @@ describe("BucketClient", () => { name: "John", }, context: { - active: false, + active: true, }, type: "user", userId: "user123", @@ -1544,7 +1548,7 @@ describe("BucketClient", () => { client.getFeatures({ user, company, other: otherContext }); expect(isAllowedSpy).toHaveBeenCalledWith( - "f1e5f547723da57ad12375f304e44ed6f74c744e", + "d461e93fe41f6297ab43402d0fc6d63e2444e07d", ); }); From ba00acc9698159a1e31a8cb12715a071393ce09f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 08:44:35 +0100 Subject: [PATCH 27/60] chore(deps-dev): bump vitest from 2.0.4 to 2.1.9 (#314) --- yarn.lock | 452 ++++++++++++++++++++++++------------------------------ 1 file changed, 202 insertions(+), 250 deletions(-) diff --git a/yarn.lock b/yarn.lock index b05fd49b..7f1ce407 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29,16 +29,6 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:^2.3.0": - version: 2.3.0 - resolution: "@ampproject/remapping@npm:2.3.0" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed - languageName: node - linkType: hard - "@asamuzakjp/dom-selector@npm:^2.0.1": version: 2.0.2 resolution: "@asamuzakjp/dom-selector@npm:2.0.2" @@ -2030,6 +2020,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" @@ -4031,183 +4028,138 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/expect@npm:1.6.0" +"@vitest/expect@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/expect@npm:1.6.1" dependencies: - "@vitest/spy": "npm:1.6.0" - "@vitest/utils": "npm:1.6.0" + "@vitest/spy": "npm:1.6.1" + "@vitest/utils": "npm:1.6.1" chai: "npm:^4.3.10" - checksum: 10c0/a4351f912a70543e04960f5694f1f1ac95f71a856a46e87bba27d3eb72a08c5d11d35021cbdc6077452a152e7d93723fc804bba76c2cc53c8896b7789caadae3 - languageName: node - linkType: hard - -"@vitest/expect@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/expect@npm:2.0.4" - dependencies: - "@vitest/spy": "npm:2.0.4" - "@vitest/utils": "npm:2.0.4" - chai: "npm:^5.1.1" - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/18acdd6b1f5001830722fab7d41b0bd754e37572dded74d1549c5e8f40e58d9e4bbbb6a8ce6be1200b04653237329ba1aeeb3330c2a41f1024450016464d491e + checksum: 10c0/278164b2a32a7019b443444f21111c5e32e4cadee026cae047ae2a3b347d99dca1d1fb7b79509c88b67dc3db19fa9a16265b7d7a8377485f7e37f7851e44495a languageName: node linkType: hard -"@vitest/expect@npm:2.0.5": - version: 2.0.5 - resolution: "@vitest/expect@npm:2.0.5" +"@vitest/expect@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/expect@npm:2.1.9" dependencies: - "@vitest/spy": "npm:2.0.5" - "@vitest/utils": "npm:2.0.5" - chai: "npm:^5.1.1" + "@vitest/spy": "npm:2.1.9" + "@vitest/utils": "npm:2.1.9" + chai: "npm:^5.1.2" tinyrainbow: "npm:^1.2.0" - checksum: 10c0/08cb1b0f106d16a5b60db733e3d436fa5eefc68571488eb570dfe4f599f214ab52e4342273b03dbe12331cc6c0cdc325ac6c94f651ad254cd62f3aa0e3d185aa + checksum: 10c0/98d1cf02917316bebef9e4720723e38298a1c12b3c8f3a81f259bb822de4288edf594e69ff64f0b88afbda6d04d7a4f0c2f720f3fec16b4c45f5e2669f09fdbb 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" +"@vitest/mocker@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/mocker@npm:2.1.9" dependencies: - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/c2ac3ca302b93ad53ea2977209ee4eb31a313c18690034a09f8ec5528d7e82715c233c4927ecf8b364203c5e5475231d9b737b3fb7680eea71882e1eae11e473 + "@vitest/spy": "npm:2.1.9" + 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/f734490d8d1206a7f44dfdfca459282f5921d73efa72935bb1dc45307578defd38a4131b14853316373ec364cbe910dbc74594ed4137e0da35aa4d9bb716f190 languageName: node linkType: hard -"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.5": - version: 2.0.5 - resolution: "@vitest/pretty-format@npm:2.0.5" +"@vitest/pretty-format@npm:2.1.9, @vitest/pretty-format@npm:^2.1.9": + version: 2.1.9 + resolution: "@vitest/pretty-format@npm:2.1.9" dependencies: tinyrainbow: "npm:^1.2.0" - checksum: 10c0/236c0798c5170a0b5ad5d4bd06118533738e820b4dd30079d8fbcb15baee949d41c60f42a9f769906c4a5ce366d7ef11279546070646c0efc03128c220c31f37 + checksum: 10c0/155f9ede5090eabed2a73361094bb35ed4ec6769ae3546d2a2af139166569aec41bb80e031c25ff2da22b71dd4ed51e5468e66a05e6aeda5f14b32e30bc18f00 languageName: node linkType: hard -"@vitest/runner@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/runner@npm:1.6.0" +"@vitest/runner@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/runner@npm:1.6.1" dependencies: - "@vitest/utils": "npm:1.6.0" + "@vitest/utils": "npm:1.6.1" p-limit: "npm:^5.0.0" pathe: "npm:^1.1.1" - checksum: 10c0/27d67fa51f40effe0e41ee5f26563c12c0ef9a96161f806036f02ea5eb9980c5cdf305a70673942e7a1e3d472d4d7feb40093ae93024ef1ccc40637fc65b1d2f - languageName: node - linkType: hard - -"@vitest/runner@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/runner@npm:2.0.4" - dependencies: - "@vitest/utils": "npm:2.0.4" - pathe: "npm:^1.1.2" - checksum: 10c0/b550372ce5e2c6a3f08dbd584ea669723fc0d789ebaa4224b703f12e908813fb76b963ea9ac2265aa751cab0309f637dc1fa7ce3fb3e67e08e52e241d33237ee + checksum: 10c0/36333f1a596c4ad85d42c6126cc32959c984d584ef28d366d366fa3672678c1a0f5e5c2e8717a36675b6620b57e8830f765d6712d1687f163ed0a8ebf23c87db languageName: node linkType: hard -"@vitest/runner@npm:2.0.5": - version: 2.0.5 - resolution: "@vitest/runner@npm:2.0.5" +"@vitest/runner@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/runner@npm:2.1.9" dependencies: - "@vitest/utils": "npm:2.0.5" + "@vitest/utils": "npm:2.1.9" pathe: "npm:^1.1.2" - checksum: 10c0/d0ed3302a7e015bf44b7c0df9d8f7da163659e082d86f9406944b5a31a61ab9ddc1de530e06176d1f4ef0bde994b44bff4c7dab62aacdc235c8fc04b98e4a72a + checksum: 10c0/e81f176badb12a815cbbd9bd97e19f7437a0b64e8934d680024b0f768d8670d59cad698ef0e3dada5241b6731d77a7bb3cd2c7cb29f751fd4dd35eb11c42963a languageName: node linkType: hard -"@vitest/snapshot@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/snapshot@npm:1.6.0" +"@vitest/snapshot@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/snapshot@npm:1.6.1" dependencies: magic-string: "npm:^0.30.5" pathe: "npm:^1.1.1" pretty-format: "npm:^29.7.0" - checksum: 10c0/be027fd268d524589ff50c5fad7b4faa1ac5742b59ac6c1dc6f5a3930aad553560e6d8775e90ac4dfae4be746fc732a6f134ba95606a1519707ce70db3a772a5 + checksum: 10c0/68bbc3132c195ec37376469e4b183fc408e0aeedd827dffcc899aac378e9ea324825f0873062786e18f00e3da9dd8a93c9bb871c07471ee483e8df963cb272eb languageName: node linkType: hard -"@vitest/snapshot@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/snapshot@npm:2.0.4" +"@vitest/snapshot@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/snapshot@npm:2.1.9" dependencies: - "@vitest/pretty-format": "npm:2.0.4" - magic-string: "npm:^0.30.10" + "@vitest/pretty-format": "npm:2.1.9" + magic-string: "npm:^0.30.12" pathe: "npm:^1.1.2" - checksum: 10c0/67608c5b1e2f8b02ebc95286cd644c31ea29344c81d67151375b6eebf088a0eea242756eefb509aac626b8f7f091044fdcbc80d137d811ead1117a4a524e2d74 + checksum: 10c0/394974b3a1fe96186a3c87f933b2f7f1f7b7cc42f9c781d80271dbb4c987809bf035fecd7398b8a3a2d54169e3ecb49655e38a0131d0e7fea5ce88960613b526 languageName: node linkType: hard -"@vitest/snapshot@npm:2.0.5": - version: 2.0.5 - resolution: "@vitest/snapshot@npm:2.0.5" - dependencies: - "@vitest/pretty-format": "npm:2.0.5" - magic-string: "npm:^0.30.10" - pathe: "npm:^1.1.2" - checksum: 10c0/7bf38474248f5ae0aac6afad511785d2b7a023ac5158803c2868fd172b5b9c1a569fb1dd64a09a49e43fd342cab71ea485ada89b7f08d37b1622a5a0ac00271d - languageName: node - linkType: hard - -"@vitest/spy@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/spy@npm:1.6.0" +"@vitest/spy@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/spy@npm:1.6.1" dependencies: tinyspy: "npm:^2.2.0" - checksum: 10c0/df66ea6632b44fb76ef6a65c1abbace13d883703aff37cd6d062add6dcd1b883f19ce733af8e0f7feb185b61600c6eb4042a518e4fb66323d0690ec357f9401c - languageName: node - linkType: hard - -"@vitest/spy@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/spy@npm:2.0.4" - dependencies: - tinyspy: "npm:^3.0.0" - checksum: 10c0/ef0d0c5e36bb6dfa3ef7561368b39c92cd89bb52d112ec13345dfc99981796a9af98bafd35ce6952322a6a7534eaad144485fe7764628d94d77edeba5fa773b6 + checksum: 10c0/5207ec0e7882819f0e0811293ae6d14163e26927e781bb4de7d40b3bd99c1fae656934c437bb7a30443a3e7e736c5bccb037bbf4436dbbc83d29e65247888885 languageName: node linkType: hard -"@vitest/spy@npm:2.0.5": - version: 2.0.5 - resolution: "@vitest/spy@npm:2.0.5" +"@vitest/spy@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/spy@npm:2.1.9" dependencies: - tinyspy: "npm:^3.0.0" - checksum: 10c0/70634c21921eb271b54d2986c21d7ab6896a31c0f4f1d266940c9bafb8ac36237846d6736638cbf18b958bd98e5261b158a6944352742accfde50b7818ff655e + tinyspy: "npm:^3.0.2" + checksum: 10c0/12a59b5095e20188b819a1d797e0a513d991b4e6a57db679927c43b362a3eff52d823b34e855a6dd9e73c9fa138dcc5ef52210841a93db5cbf047957a60ca83c languageName: node linkType: hard -"@vitest/utils@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/utils@npm:1.6.0" +"@vitest/utils@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/utils@npm:1.6.1" dependencies: diff-sequences: "npm:^29.6.3" estree-walker: "npm:^3.0.3" loupe: "npm:^2.3.7" pretty-format: "npm:^29.7.0" - checksum: 10c0/8b0d19835866455eb0b02b31c5ca3d8ad45f41a24e4c7e1f064b480f6b2804dc895a70af332f14c11ed89581011b92b179718523f55f5b14787285a0321b1301 + checksum: 10c0/0d4c619e5688cbc22a60c412719c6baa40376b7671bdbdc3072552f5c5a5ee5d24a96ea328b054018debd49e0626a5e3db672921b2c6b5b17b9a52edd296806a languageName: node linkType: hard -"@vitest/utils@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/utils@npm:2.0.4" +"@vitest/utils@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/utils@npm:2.1.9" dependencies: - "@vitest/pretty-format": "npm:2.0.4" - estree-walker: "npm:^3.0.3" - loupe: "npm:^3.1.1" + "@vitest/pretty-format": "npm:2.1.9" + loupe: "npm:^3.1.2" tinyrainbow: "npm:^1.2.0" - checksum: 10c0/48e0bad3aa463d147b125e355b6bc6c5b4a5eab600132ebafac8379800273b2f47df17dbf76fe179b1500cc6b5866ead2d375a39a9114a03f705eb8850b93afa - languageName: node - linkType: hard - -"@vitest/utils@npm:2.0.5": - version: 2.0.5 - resolution: "@vitest/utils@npm:2.0.5" - dependencies: - "@vitest/pretty-format": "npm:2.0.5" - estree-walker: "npm:^3.0.3" - loupe: "npm:^3.1.1" - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/0d1de748298f07a50281e1ba058b05dcd58da3280c14e6f016265e950bd79adab6b97822de8f0ea82d3070f585654801a9b1bcf26db4372e51cf7746bf86d73b + checksum: 10c0/81a346cd72b47941f55411f5df4cc230e5f740d1e97e0d3f771b27f007266fc1f28d0438582f6409ea571bc0030ed37f684c64c58d1947d6298d770c21026fdf languageName: node linkType: hard @@ -5665,16 +5617,16 @@ __metadata: languageName: node linkType: hard -"chai@npm:^5.1.1": - version: 5.1.1 - resolution: "chai@npm:5.1.1" +"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/e7f00e5881e3d5224f08fe63966ed6566bd9fdde175863c7c16dd5240416de9b34c4a0dd925f4fd64ad56256ca6507d32cf6131c49e1db65c62578eb31d4566c + checksum: 10c0/6c04ff8495b6e535df9c1b062b6b094828454e9a3c9493393e55b2f4dbff7aa2a29a4645133cad160fb00a16196c4dc03dc9bb37e1f4ba9df3b5f50d7533a736 languageName: node linkType: hard @@ -6432,6 +6384,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" @@ -7041,6 +7005,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" @@ -7646,6 +7617,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" @@ -10184,7 +10162,7 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.1.0, loupe@npm:^3.1.1": +"loupe@npm:^3.1.0": version: 3.1.1 resolution: "loupe@npm:3.1.1" dependencies: @@ -10193,6 +10171,13 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^3.1.2": + version: 3.1.3 + resolution: "loupe@npm:3.1.3" + checksum: 10c0/f5dab4144254677de83a35285be1b8aba58b3861439ce4ba65875d0d5f3445a4a496daef63100ccf02b2dbc25bf58c6db84c9cb0b96d6435331e9d0a33b48541 + 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" @@ -10259,6 +10244,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" @@ -10761,7 +10755,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 @@ -14120,13 +14114,20 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.5.0, std-env@npm:^3.7.0": +"std-env@npm:^3.5.0": version: 3.7.0 resolution: "std-env@npm:3.7.0" checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e 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 + "stop-iteration-iterator@npm:^1.0.0": version: 1.0.0 resolution: "stop-iteration-iterator@npm:1.0.0" @@ -14653,13 +14654,27 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.5.1, tinybench@npm:^2.8.0": +"tinybench@npm:^2.5.1": version: 2.8.0 resolution: "tinybench@npm:2.8.0" checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d 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" @@ -14667,10 +14682,10 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^1.0.0": - version: 1.0.0 - resolution: "tinypool@npm:1.0.0" - checksum: 10c0/71b20b9c54366393831c286a0772380c20f8cad9546d724c484edb47aea3228f274c58e98cf51d28c40869b39f5273209ef3ea94a9d2a23f8b292f4731cd3e4e +"tinypool@npm:^1.0.1": + version: 1.0.2 + resolution: "tinypool@npm:1.0.2" + checksum: 10c0/31ac184c0ff1cf9a074741254fe9ea6de95026749eb2b8ec6fd2b9d8ca94abdccda731f8e102e7f32e72ed3b36d32c6975fd5f5523df3f1b6de6c3d8dfd95e63 languageName: node linkType: hard @@ -14688,10 +14703,10 @@ __metadata: languageName: node linkType: hard -"tinyspy@npm:^3.0.0": - version: 3.0.0 - resolution: "tinyspy@npm:3.0.0" - checksum: 10c0/eb0dec264aa5370efd3d29743825eb115ed7f1ef8a72a431e9a75d5c9e7d67e99d04b0d61d86b8cd70c79ec27863f241ad0317bc453f78762e0cbd76d2c332d0 +"tinyspy@npm:^3.0.2": + version: 3.0.2 + resolution: "tinyspy@npm:3.0.2" + checksum: 10c0/55ffad24e346622b59292e097c2ee30a63919d5acb7ceca87fc0d1c223090089890587b426e20054733f97a58f20af2c349fb7cc193697203868ab7ba00bcea0 languageName: node linkType: hard @@ -15452,9 +15467,9 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:1.6.0": - version: 1.6.0 - resolution: "vite-node@npm:1.6.0" +"vite-node@npm:1.6.1": + version: 1.6.1 + resolution: "vite-node@npm:1.6.1" dependencies: cac: "npm:^6.7.14" debug: "npm:^4.3.4" @@ -15463,37 +15478,22 @@ __metadata: vite: "npm:^5.0.0" bin: vite-node: vite-node.mjs - checksum: 10c0/0807e6501ac7763e0efa2b4bd484ce99fb207e92c98624c9f8999d1f6727ac026e457994260fa7fdb7060d87546d197081e46a705d05b0136a38b6f03715cbc2 + checksum: 10c0/4d96da9f11bd0df8b60c46e65a740edaad7dd2d1aff3cdb3da5714ea8c10b5f2683111b60bfe45545c7e8c1f33e7e8a5095573d5e9ba55f50a845233292c2e02 languageName: node linkType: hard -"vite-node@npm:2.0.4": - version: 2.0.4 - resolution: "vite-node@npm:2.0.4" +"vite-node@npm:2.1.9": + version: 2.1.9 + resolution: "vite-node@npm:2.1.9" dependencies: cac: "npm:^6.7.14" - debug: "npm:^4.3.5" - pathe: "npm:^1.1.2" - tinyrainbow: "npm:^1.2.0" - vite: "npm:^5.0.0" - bin: - vite-node: vite-node.mjs - checksum: 10c0/2689b05b391b59cf3d15e1e80884e9b054f2ca90b2150cc7a08b0f234e79e6750a28cc8d107a57f005185e759c3bc020030f687065317fc37fe169ce17f4cdb7 - languageName: node - linkType: hard - -"vite-node@npm:2.0.5": - version: 2.0.5 - resolution: "vite-node@npm:2.0.5" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.3.5" + debug: "npm:^4.3.7" + es-module-lexer: "npm:^1.5.4" pathe: "npm:^1.1.2" - tinyrainbow: "npm:^1.2.0" vite: "npm:^5.0.0" bin: vite-node: vite-node.mjs - checksum: 10c0/affcc58ae8d45bce3e8bc3b5767acd57c24441634e2cd967cf97f4e5ed2bcead1714b60150cdf7ee153ebad47659c5cd419883207e1a95b69790331e3243749f + checksum: 10c0/0d3589f9f4e9cff696b5b49681fdb75d1638c75053728be52b4013f70792f38cb0120a9c15e3a4b22bdd6b795ad7c2da13bcaf47242d439f0906049e73bdd756 languageName: node linkType: hard @@ -15646,14 +15646,14 @@ __metadata: linkType: hard "vitest@npm:^1.1.3, vitest@npm:~1.6.0": - version: 1.6.0 - resolution: "vitest@npm:1.6.0" - dependencies: - "@vitest/expect": "npm:1.6.0" - "@vitest/runner": "npm:1.6.0" - "@vitest/snapshot": "npm:1.6.0" - "@vitest/spy": "npm:1.6.0" - "@vitest/utils": "npm:1.6.0" + version: 1.6.1 + resolution: "vitest@npm:1.6.1" + dependencies: + "@vitest/expect": "npm:1.6.1" + "@vitest/runner": "npm:1.6.1" + "@vitest/snapshot": "npm:1.6.1" + "@vitest/spy": "npm:1.6.1" + "@vitest/utils": "npm:1.6.1" acorn-walk: "npm:^8.3.2" chai: "npm:^4.3.10" debug: "npm:^4.3.4" @@ -15667,13 +15667,13 @@ __metadata: tinybench: "npm:^2.5.1" tinypool: "npm:^0.8.3" vite: "npm:^5.0.0" - vite-node: "npm:1.6.0" + vite-node: "npm:1.6.1" why-is-node-running: "npm:^2.2.2" peerDependencies: "@edge-runtime/vm": "*" "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 1.6.0 - "@vitest/ui": 1.6.0 + "@vitest/browser": 1.6.1 + "@vitest/ui": 1.6.1 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -15691,87 +15691,39 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/065da5b8ead51eb174d93dac0cd50042ca9539856dc25e340ea905d668c41961f7e00df3e388e6c76125b2c22091db2e8465f993d0f6944daf9598d549e562e7 + checksum: 10c0/511d27d7f697683964826db2fad7ac303f9bc7eeb59d9422111dc488371ccf1f9eed47ac3a80eb47ca86b7242228ba5ca9cc3613290830d0e916973768cac215 languageName: node linkType: hard -"vitest@npm:^2.0.4": - version: 2.0.4 - resolution: "vitest@npm:2.0.4" - dependencies: - "@ampproject/remapping": "npm:^2.3.0" - "@vitest/expect": "npm:2.0.4" - "@vitest/pretty-format": "npm:^2.0.4" - "@vitest/runner": "npm:2.0.4" - "@vitest/snapshot": "npm:2.0.4" - "@vitest/spy": "npm:2.0.4" - "@vitest/utils": "npm:2.0.4" - chai: "npm:^5.1.1" - debug: "npm:^4.3.5" - execa: "npm:^8.0.1" - magic-string: "npm:^0.30.10" - pathe: "npm:^1.1.2" - std-env: "npm:^3.7.0" - tinybench: "npm:^2.8.0" - tinypool: "npm:^1.0.0" - tinyrainbow: "npm:^1.2.0" - vite: "npm:^5.0.0" - vite-node: "npm:2.0.4" - why-is-node-running: "npm:^2.3.0" - peerDependencies: - "@edge-runtime/vm": "*" - "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 2.0.4 - "@vitest/ui": 2.0.4 - 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/139200d0bda3270fd00641e4bd5524f78a2b1fe9a3d4a0d5ba2b6ed08bbcf6f1e711cc4bfd8b0d823628a2fcab00f822bb210bd5bf3c6a9260fd6115ea085a3d - languageName: node - linkType: hard - -"vitest@npm:^2.0.5": - version: 2.0.5 - resolution: "vitest@npm:2.0.5" - dependencies: - "@ampproject/remapping": "npm:^2.3.0" - "@vitest/expect": "npm:2.0.5" - "@vitest/pretty-format": "npm:^2.0.5" - "@vitest/runner": "npm:2.0.5" - "@vitest/snapshot": "npm:2.0.5" - "@vitest/spy": "npm:2.0.5" - "@vitest/utils": "npm:2.0.5" - chai: "npm:^5.1.1" - debug: "npm:^4.3.5" - execa: "npm:^8.0.1" - magic-string: "npm:^0.30.10" +"vitest@npm:^2.0.4, vitest@npm:^2.0.5": + version: 2.1.9 + resolution: "vitest@npm:2.1.9" + dependencies: + "@vitest/expect": "npm:2.1.9" + "@vitest/mocker": "npm:2.1.9" + "@vitest/pretty-format": "npm:^2.1.9" + "@vitest/runner": "npm:2.1.9" + "@vitest/snapshot": "npm:2.1.9" + "@vitest/spy": "npm:2.1.9" + "@vitest/utils": "npm:2.1.9" + 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.7.0" - tinybench: "npm:^2.8.0" - tinypool: "npm:^1.0.0" + 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.0.5" + vite-node: "npm:2.1.9" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 2.0.5 - "@vitest/ui": 2.0.5 + "@vitest/browser": 2.1.9 + "@vitest/ui": 2.1.9 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -15789,7 +15741,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/b4e6cca00816bf967a8589111ded72faa12f92f94ccdd0dcd0698ffcfdfc52ec662753f66b387549c600ac699b993fd952efbd99dc57fcf4d1c69a2f1022b259 + checksum: 10c0/e339e16dccacf4589ff43cb1f38c7b4d14427956ae8ef48702af6820a9842347c2b6c77356aeddb040329759ca508a3cb2b104ddf78103ea5bc98ab8f2c3a54e languageName: node linkType: hard From eadb49106532ac0842a03606d44a4d5908e87cea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:31:14 +0000 Subject: [PATCH 28/60] chore(deps-dev): bump vitest from 2.1.4 to 2.1.9 in /packages/node-sdk/example (#313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 2.1.4 to 2.1.9.
Release notes

Sourced from vitest's releases.

v2.1.9

This release includes security patches for:

   🐞 Bug Fixes

    View changes on GitHub

v2.1.8

   🐞 Bug Fixes

    View changes on GitHub

v2.1.7

   🐞 Bug Fixes

  • Revert support for Vite 6  -  by @​sheremet-va (fbe5c)
    • This introduced some breaking changes (vitest-dev/vitest#6992). We will enable support for it later. In the meantime, you can still use pnpm.overrides or yarn resolutions to override the vite version in the vitest package - the APIs are compatible.
    View changes on GitHub

v2.1.6

🚀 Features

  • Support Vite 6
    View changes on GitHub

v2.1.5

   🐞 Bug Fixes

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vitest&package-manager=npm_and_yarn&previous-version=2.1.4&new-version=2.1.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/bucketco/bucket-javascript-sdk/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexandru Ciobanu --- packages/node-sdk/example/package.json | 2 +- packages/node-sdk/example/yarn.lock | 126 +++++++++++++------------ 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/packages/node-sdk/example/package.json b/packages/node-sdk/example/package.json index 7fc4aea3..1fd3758f 100644 --- a/packages/node-sdk/example/package.json +++ b/packages/node-sdk/example/package.json @@ -16,6 +16,6 @@ "@types/express": "^4.17.21", "@types/supertest": "^6.0.2", "supertest": "^7.0.0", - "vitest": "^2.1.4" + "vitest": "^2.1.9" } } diff --git a/packages/node-sdk/example/yarn.lock b/packages/node-sdk/example/yarn.lock index 2a66e7af..03f1c516 100644 --- a/packages/node-sdk/example/yarn.lock +++ b/packages/node-sdk/example/yarn.lock @@ -486,23 +486,23 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:2.1.4": - version: 2.1.4 - resolution: "@vitest/expect@npm:2.1.4" +"@vitest/expect@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/expect@npm:2.1.9" dependencies: - "@vitest/spy": "npm:2.1.4" - "@vitest/utils": "npm:2.1.4" + "@vitest/spy": "npm:2.1.9" + "@vitest/utils": "npm:2.1.9" chai: "npm:^5.1.2" tinyrainbow: "npm:^1.2.0" - checksum: 10c0/cd20ec6f92479fe5d155221d7623cf506a84e10f537639c93b8a2ffba7314b65f0fcab3754ba31308a0381470fea2e3c53d283e5f5be2c592a69d7e817a85571 + checksum: 10c0/98d1cf02917316bebef9e4720723e38298a1c12b3c8f3a81f259bb822de4288edf594e69ff64f0b88afbda6d04d7a4f0c2f720f3fec16b4c45f5e2669f09fdbb languageName: node linkType: hard -"@vitest/mocker@npm:2.1.4": - version: 2.1.4 - resolution: "@vitest/mocker@npm:2.1.4" +"@vitest/mocker@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/mocker@npm:2.1.9" dependencies: - "@vitest/spy": "npm:2.1.4" + "@vitest/spy": "npm:2.1.9" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.12" peerDependencies: @@ -513,57 +513,57 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/3327ec34d05f25e17c0a083877e204a31ffc4150fb259e8f82191aa5328f456e81374b977e56db17c835bd29a7eaba249e011c21b27a52bf31fd4127104d4662 + checksum: 10c0/f734490d8d1206a7f44dfdfca459282f5921d73efa72935bb1dc45307578defd38a4131b14853316373ec364cbe910dbc74594ed4137e0da35aa4d9bb716f190 languageName: node linkType: hard -"@vitest/pretty-format@npm:2.1.4, @vitest/pretty-format@npm:^2.1.4": - version: 2.1.4 - resolution: "@vitest/pretty-format@npm:2.1.4" +"@vitest/pretty-format@npm:2.1.9, @vitest/pretty-format@npm:^2.1.9": + version: 2.1.9 + resolution: "@vitest/pretty-format@npm:2.1.9" dependencies: tinyrainbow: "npm:^1.2.0" - checksum: 10c0/dc20f04f64c95731bf9640fc53ae918d928ab93e70a56d9e03f201700098cdb041b50a8f6a5f30604d4a048c15f315537453f33054e29590a05d5b368ae6849d + checksum: 10c0/155f9ede5090eabed2a73361094bb35ed4ec6769ae3546d2a2af139166569aec41bb80e031c25ff2da22b71dd4ed51e5468e66a05e6aeda5f14b32e30bc18f00 languageName: node linkType: hard -"@vitest/runner@npm:2.1.4": - version: 2.1.4 - resolution: "@vitest/runner@npm:2.1.4" +"@vitest/runner@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/runner@npm:2.1.9" dependencies: - "@vitest/utils": "npm:2.1.4" + "@vitest/utils": "npm:2.1.9" pathe: "npm:^1.1.2" - checksum: 10c0/be51bb7f63b6d524bed2b44bafa8022ac5019bc01a411497c8b607d13601dae40a592bad6b8e21096f02827bd256296354947525d038a2c04032fdaa9ca991f0 + checksum: 10c0/e81f176badb12a815cbbd9bd97e19f7437a0b64e8934d680024b0f768d8670d59cad698ef0e3dada5241b6731d77a7bb3cd2c7cb29f751fd4dd35eb11c42963a languageName: node linkType: hard -"@vitest/snapshot@npm:2.1.4": - version: 2.1.4 - resolution: "@vitest/snapshot@npm:2.1.4" +"@vitest/snapshot@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/snapshot@npm:2.1.9" dependencies: - "@vitest/pretty-format": "npm:2.1.4" + "@vitest/pretty-format": "npm:2.1.9" magic-string: "npm:^0.30.12" pathe: "npm:^1.1.2" - checksum: 10c0/50e15398420870755e03d7d0cb7825642021e4974cb26760b8159f0c8273796732694b6a9a703a7cff88790ca4bb09f38bfc174396bcc7cbb93b96e5ac21d1d7 + checksum: 10c0/394974b3a1fe96186a3c87f933b2f7f1f7b7cc42f9c781d80271dbb4c987809bf035fecd7398b8a3a2d54169e3ecb49655e38a0131d0e7fea5ce88960613b526 languageName: node linkType: hard -"@vitest/spy@npm:2.1.4": - version: 2.1.4 - resolution: "@vitest/spy@npm:2.1.4" +"@vitest/spy@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/spy@npm:2.1.9" dependencies: tinyspy: "npm:^3.0.2" - checksum: 10c0/a983efa140fa5211dc96a0c7c5110883c8095d00c45e711ecde1cc4a862560055b0e24907ae55970ab4a034e52265b7e8e70168f0da4b500b448d3d214eb045e + checksum: 10c0/12a59b5095e20188b819a1d797e0a513d991b4e6a57db679927c43b362a3eff52d823b34e855a6dd9e73c9fa138dcc5ef52210841a93db5cbf047957a60ca83c languageName: node linkType: hard -"@vitest/utils@npm:2.1.4": - version: 2.1.4 - resolution: "@vitest/utils@npm:2.1.4" +"@vitest/utils@npm:2.1.9": + version: 2.1.9 + resolution: "@vitest/utils@npm:2.1.9" dependencies: - "@vitest/pretty-format": "npm:2.1.4" + "@vitest/pretty-format": "npm:2.1.9" loupe: "npm:^3.1.2" tinyrainbow: "npm:^1.2.0" - checksum: 10c0/fd632dbc2496d14bcc609230f1dad73039c9f52f4ca533d6b68fa1a04dd448e03510f2a8e4a368fd274cbb8902a6cd800140ab366dd055256beb2c0dcafcd9f2 + checksum: 10c0/81a346cd72b47941f55411f5df4cc230e5f740d1e97e0d3f771b27f007266fc1f28d0438582f6409ea571bc0030ed37f684c64c58d1947d6298d770c21026fdf languageName: node linkType: hard @@ -1021,6 +1021,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 + "esbuild@npm:^0.21.3, esbuild@npm:~0.21.5": version: 0.21.5 resolution: "esbuild@npm:0.21.5" @@ -1134,7 +1141,7 @@ __metadata: supertest: "npm:^7.0.0" tsx: "npm:^4.16.2" typescript: "npm:^5.5.3" - vitest: "npm:^2.1.4" + vitest: "npm:^2.1.9" languageName: unknown linkType: soft @@ -2234,10 +2241,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.7.0": - version: 3.7.0 - resolution: "std-env@npm:3.7.0" - checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e +"std-env@npm:^3.8.0": + version: 3.8.0 + resolution: "std-env@npm:3.8.0" + checksum: 10c0/f560a2902fd0fa3d648d7d0acecbd19d664006f7372c1fba197ed4c216b4c9e48db6e2769b5fe1616d42a9333c9f066c5011935035e85c59f45dc4f796272040 languageName: node linkType: hard @@ -2456,17 +2463,18 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:2.1.4": - version: 2.1.4 - resolution: "vite-node@npm:2.1.4" +"vite-node@npm:2.1.9": + version: 2.1.9 + resolution: "vite-node@npm:2.1.9" 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/4c09128f27ded3f681d2c034f0bb74856cef9cad9c437951bc7f95dab92fc95a5d1ee7f54e32067458ad1105e1f24975e8bc64aa7ed8f5b33449b4f5fea65919 + checksum: 10c0/0d3589f9f4e9cff696b5b49681fdb75d1638c75053728be52b4013f70792f38cb0120a9c15e3a4b22bdd6b795ad7c2da13bcaf47242d439f0906049e73bdd756 languageName: node linkType: hard @@ -2513,35 +2521,35 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^2.1.4": - version: 2.1.4 - resolution: "vitest@npm:2.1.4" - dependencies: - "@vitest/expect": "npm:2.1.4" - "@vitest/mocker": "npm:2.1.4" - "@vitest/pretty-format": "npm:^2.1.4" - "@vitest/runner": "npm:2.1.4" - "@vitest/snapshot": "npm:2.1.4" - "@vitest/spy": "npm:2.1.4" - "@vitest/utils": "npm:2.1.4" +"vitest@npm:^2.1.9": + version: 2.1.9 + resolution: "vitest@npm:2.1.9" + dependencies: + "@vitest/expect": "npm:2.1.9" + "@vitest/mocker": "npm:2.1.9" + "@vitest/pretty-format": "npm:^2.1.9" + "@vitest/runner": "npm:2.1.9" + "@vitest/snapshot": "npm:2.1.9" + "@vitest/spy": "npm:2.1.9" + "@vitest/utils": "npm:2.1.9" 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.7.0" + 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.4" + vite-node: "npm:2.1.9" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 2.1.4 - "@vitest/ui": 2.1.4 + "@vitest/browser": 2.1.9 + "@vitest/ui": 2.1.9 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -2559,7 +2567,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/96068ea6d40186c8ca946ee688ba3717dbd0947c56a2bcd625c14a5df25776342ff2f1eb326b06cb6f538d9568633b3e821991aa7c95a98e458be9fc2b3ca59e + checksum: 10c0/e339e16dccacf4589ff43cb1f38c7b4d14427956ae8ef48702af6820a9842347c2b6c77356aeddb040329759ca508a3cb2b104ddf78103ea5bc98ab8f2c3a54e languageName: node linkType: hard From 6d9f894309313630e00045adc51305923c2cbba9 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 12 Feb 2025 14:43:56 +0100 Subject: [PATCH 29/60] feat(openfeature-node-provider): improve flag resolution and context handling (#318) - Enhanced flag resolution methods with more robust error handling - Added support for more context attributes in context translator - Improved type checking and resolution for different flag types - Updated README with more detailed usage and resolution method explanations - Bumped node-sdk dependency to 1.6.0-alpha.3 --- packages/openfeature-node-provider/README.md | 96 ++++- .../openfeature-node-provider/package.json | 4 +- .../src/index.test.ts | 347 ++++++++++++++---- .../openfeature-node-provider/src/index.ts | 143 ++++++-- yarn.lock | 10 +- 5 files changed, 463 insertions(+), 137 deletions(-) diff --git a/packages/openfeature-node-provider/README.md b/packages/openfeature-node-provider/README.md index f9183534..75699165 100644 --- a/packages/openfeature-node-provider/README.md +++ b/packages/openfeature-node-provider/README.md @@ -4,26 +4,23 @@ The official OpenFeature Node.js provider for [Bucket](https://bucket.co) featur ## Installation -``` -$ npm install @bucketco/openfeature-node-provider +```shell +npm install @bucketco/openfeature-node-provider ``` -#### Required peer dependencies +### Required peer dependencies The OpenFeature SDK is required as peer dependency. - The minimum required version of `@openfeature/server-sdk` currently is `1.13.5`. - The minimum required version of `@bucketco/node-sdk` currently is `2.0.0`. -``` -$ npm install @openfeature/server-sdk @bucketco/node-sdk +```shell +npm install @openfeature/server-sdk @bucketco/node-sdk ``` ## Usage The provider uses the [Bucket Node.js SDK](https://docs.bucket.co/quickstart/supported-languages-frameworks/node.js-sdk). - The available options can be found in the [Bucket Node.js SDK](https://github.com/bucketco/bucket-javascript-sdk/tree/main/packages/node-sdk#initialization-options). ### Example using the default configuration @@ -56,6 +53,59 @@ const enterpriseFeatureEnabled = await client.getBooleanValue( ); ``` +## Feature resolution methods + +The Bucket OpenFeature Provider implements the OpenFeature evaluation interface for different value types. Each method handles the resolution of feature flags according to the OpenFeature specification. + +### Common behavior + +All resolution methods share these behaviors: + +- Return default value with `PROVIDER_NOT_READY` if client is not initialized, +- Return default value with `FLAG_NOT_FOUND` if flag doesn't exist, +- Return default value with `ERROR` if there was a type mismatch, +- Return evaluated value with `TARGETING_MATCH` on successful resolution. + +### Type-Specific Methods + +#### Boolean Resolution + +```ts +client.getBooleanValue("my-flag", false); +``` + +Returns the feature's enabled state. This is the most common use case for feature flags. + +#### String Resolution + +```ts +client.getStringValue("my-flag", "default"); +``` + +Returns the feature's remote config key (also known as "variant"). Useful for multi-variate use cases. + +#### Number Resolution + +```ts +client.getNumberValue("my-flag", 0); +``` + +Not directly supported by Bucket. Use `getObjectValue` instead for numeric configurations. + +#### Object Resolution + +```ts +// works for any type: +client.getObjectValue("my-flag", { defaultValue: true }); +client.getObjectValue("my-flag", "string-value"); +client.getObjectValue("my-flag", 199); +``` + +Returns the feature's remote config payload with type validation. This is the most flexible method, +allowing for complex configuration objects or simple types. + +The object resolution performs runtime type checking between the default value and the feature payload to ensure type safety. + ## Translating Evaluation Context Bucket uses a context object of the following shape: @@ -69,11 +119,19 @@ export type BucketContext = { /** * The user context. If the user is set, the user ID is required. */ - user?: { id: string; [k: string]: any }; + user?: { + id: string; + name?: string; + email?: string; + avatar?: string; + [k: string]: any; + }; + /** * The company context. If the company is set, the company ID is required. */ - company?: { id: string; [k: string]: any }; + company?: { id: string; name?: string; avatar?: string; [k: string]: any }; + /** * The other context. This is used for any additional context that is not related to user or company. */ @@ -91,14 +149,17 @@ import { BucketNodeProvider } from "@openfeature/bucket-node-provider"; const contextTranslator = (context: EvaluationContext): BucketContext => { return { user: { - id: context.targetingKey, + id: context.targetingKey ?? context["userId"]?.toString(), name: context["name"]?.toString(), email: context["email"]?.toString(), + avatar: context["avatar"]?.toString(), country: context["country"]?.toString(), }, company: { - id: context["companyId"], - name: context["companyName"], + id: context["companyId"]?.toString(), + name: context["companyName"]?.toString(), + avatar: context["companyAvatar"]?.toString(), + plan: context["companyPlan"]?.toString(), }, }; }; @@ -115,7 +176,7 @@ It's straight forward to start sending tracking events through OpenFeature. Simply call the "track" method on the OpenFeature client: -```ts +```typescript import { BucketNodeProvider } from "@bucketco/openfeature-node-provider"; import { OpenFeature } from "@openfeature/server-sdk"; @@ -132,8 +193,7 @@ const enterpriseFeatureEnabled = await client.track( ); ``` -# License - -MIT License +## License -Copyright (c) 2025 Bucket ApS +> MIT License +> Copyright (c) 2025 Bucket ApS diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json index 06eca2f5..5cf1a21a 100644 --- a/packages/openfeature-node-provider/package.json +++ b/packages/openfeature-node-provider/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/openfeature-node-provider", - "version": "0.2.1", + "version": "0.3.0-alpha.0", "license": "MIT", "repository": { "type": "git", @@ -44,7 +44,7 @@ "vitest": "~1.6.0" }, "dependencies": { - "@bucketco/node-sdk": ">=1.4.2" + "@bucketco/node-sdk": "1.6.0-alpha.3" }, "peerDependencies": { "@openfeature/server-sdk": ">=1.16.1" diff --git a/packages/openfeature-node-provider/src/index.test.ts b/packages/openfeature-node-provider/src/index.test.ts index 8f506df1..9d2b63b9 100644 --- a/packages/openfeature-node-provider/src/index.test.ts +++ b/packages/openfeature-node-provider/src/index.test.ts @@ -1,9 +1,9 @@ -import { ErrorCode } from "@openfeature/core"; -import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { ProviderStatus } from "@openfeature/server-sdk"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { BucketClient } from "@bucketco/node-sdk"; -import { BucketNodeProvider } from "./index"; +import { BucketNodeProvider, defaultContextTranslator } from "./index"; vi.mock("@bucketco/node-sdk", () => { const actualModule = vi.importActual("@bucketco/node-sdk"); @@ -17,6 +17,7 @@ vi.mock("@bucketco/node-sdk", () => { const bucketClientMock = { getFeatures: vi.fn(), + getFeature: vi.fn(), initialize: vi.fn().mockResolvedValue({}), flush: vi.fn(), track: vi.fn(), @@ -35,6 +36,8 @@ const bucketContext = { company: { id: "99" }, }; +const testFlagKey = "a-key"; + beforeEach(() => { vi.clearAllMocks(); }); @@ -42,121 +45,305 @@ beforeEach(() => { describe("BucketNodeProvider", () => { let provider: BucketNodeProvider; - const newBucketClient = BucketClient as Mock; - newBucketClient.mockReturnValue(bucketClientMock); + const mockBucketClient = BucketClient as Mock; + mockBucketClient.mockReturnValue(bucketClientMock); + + let mockTranslatorFn: Mock; + + function mockFeature( + enabled: boolean, + configKey?: string | null, + configPayload?: any, + ) { + const config = { + key: configKey, + payload: configPayload, + }; + + bucketClientMock.getFeature = vi.fn().mockReturnValue({ + isEnabled: enabled, + config, + }); + + bucketClientMock.getFeatures = vi.fn().mockReturnValue({ + [testFlagKey]: { + isEnabled: enabled, + config: { + key: "key", + payload: configPayload, + }, + }, + }); + } - const translatorFn = vi.fn().mockReturnValue(bucketContext); + beforeEach(async () => { + mockTranslatorFn = vi.fn().mockReturnValue(bucketContext); - beforeAll(async () => { provider = new BucketNodeProvider({ secretKey, - contextTranslator: translatorFn, + contextTranslator: mockTranslatorFn, }); + await provider.initialize(); }); - it("calls the constructor", () => { - provider = new BucketNodeProvider({ - secretKey, - contextTranslator: translatorFn, + describe("contextTranslator", () => { + it("defaultContextTranslator provides the correct context", async () => { + expect( + defaultContextTranslator({ + userId: 123, + name: "John Doe", + email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", + companyId: "456", + companyName: "Acme, Inc.", + companyAvatar: "https://acme.com/company-avatar.png", + companyPlan: "pro", + }), + ).toEqual({ + user: { + id: "123", + name: "John Doe", + email: "ron@bucket.co", + avatar: "https://bucket.co/avatar.png", + }, + company: { + id: "456", + name: "Acme, Inc.", + plan: "pro", + avatar: "https://acme.com/company-avatar.png", + }, + }); + }); + + it("defaultContextTranslator uses targetingKey if provided", async () => { + expect( + defaultContextTranslator({ + targetingKey: "123", + }), + ).toMatchObject({ + user: { + id: "123", + }, + company: { + id: undefined, + }, + }); }); - expect(newBucketClient).toHaveBeenCalledTimes(1); - expect(newBucketClient).toHaveBeenCalledWith({ secretKey }); }); - it("uses the contextTranslator function", async () => { - const track = vi.fn(); - bucketClientMock.getFeatures.mockReturnValue({ - booleanTrue: { - isEnabled: true, - key: "booleanTrue", - track, - }, + describe("lifecycle", () => { + it("calls the constructor of BucketClient", () => { + mockBucketClient.mockClear(); + + provider = new BucketNodeProvider({ + secretKey, + contextTranslator: mockTranslatorFn, + }); + + expect(mockBucketClient).toHaveBeenCalledTimes(1); + expect(mockBucketClient).toHaveBeenCalledWith({ secretKey }); }); - await provider.resolveBooleanEvaluation("booleanTrue", false, context); - expect(translatorFn).toHaveBeenCalledTimes(1); - expect(translatorFn).toHaveBeenCalledWith(context); - expect(bucketClientMock.getFeatures).toHaveBeenCalledTimes(1); - expect(bucketClientMock.getFeatures).toHaveBeenCalledWith(bucketContext); + it("should set the status to READY if initialization succeeds", async () => { + provider = new BucketNodeProvider({ + secretKey, + contextTranslator: mockTranslatorFn, + }); + + await provider.initialize(); + + expect(provider.status).toBe(ProviderStatus.READY); + }); + + it("should keep the status as READY after closing", async () => { + provider = new BucketNodeProvider({ + secretKey: "invalid", + contextTranslator: mockTranslatorFn, + }); + + await provider.initialize(); + await provider.onClose(); + + expect(provider.status).toBe(ProviderStatus.READY); + }); + + it("calls flush when provider is closed", async () => { + await provider.onClose(); + expect(bucketClientMock.flush).toHaveBeenCalledTimes(1); + }); + + it("uses the contextTranslator function", async () => { + mockFeature(true); + + await provider.resolveBooleanEvaluation(testFlagKey, false, context); + + expect(mockTranslatorFn).toHaveBeenCalledTimes(1); + expect(mockTranslatorFn).toHaveBeenCalledWith(context); + + expect(bucketClientMock.getFeatures).toHaveBeenCalledTimes(1); + expect(bucketClientMock.getFeatures).toHaveBeenCalledWith(bucketContext); + }); }); - describe("method resolveBooleanEvaluation", () => { - it("should return right value if key exists", async () => { - const result = await provider.resolveBooleanEvaluation( - "booleanTrue", - false, - context, - ); - expect(result.value).toEqual(true); - expect(result.errorCode).toBeUndefined(); + describe("resolving flags", () => { + beforeEach(async () => { + await provider.initialize(); }); - it("should return the default value if key does not exists", async () => { - const result = await provider.resolveBooleanEvaluation( - "non-existent", + it("returns error if provider is not initialized", async () => { + provider = new BucketNodeProvider({ + secretKey: "invalid", + contextTranslator: mockTranslatorFn, + }); + + const val = await provider.resolveBooleanEvaluation( + testFlagKey, true, context, ); - expect(result.value).toEqual(true); - expect(result.errorCode).toEqual(ErrorCode.FLAG_NOT_FOUND); + + expect(val).toMatchObject({ + reason: "ERROR", + errorCode: "PROVIDER_NOT_READY", + value: true, + }); }); - }); - describe("method resolveNumberEvaluation", () => { - it("should return the default value and an error message", async () => { - const result = await provider.resolveNumberEvaluation("number1", 42); - expect(result.value).toEqual(42); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support number flags`, + it("returns error if flag is not found", async () => { + mockFeature(true, "key", true); + const val = await provider.resolveBooleanEvaluation( + "missing-key", + true, + context, ); + + expect(val).toMatchObject({ + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + value: true, + }); }); - }); - describe("method resolveStringEvaluation", () => { - it("should return the default value and an error message", async () => { - const result = await provider.resolveStringEvaluation( - "number1", - "defaultValue", + it("calls the client correctly when evaluating", async () => { + mockFeature(true, "key", true); + + const val = await provider.resolveBooleanEvaluation( + testFlagKey, + false, + context, ); - expect(result.value).toEqual("defaultValue"); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support string flags`, + + expect(val).toMatchObject({ + reason: "TARGETING_MATCH", + value: true, + }); + + expect(bucketClientMock.getFeatures).toHaveBeenCalled(); + expect(bucketClientMock.getFeature).toHaveBeenCalledWith( + bucketContext, + testFlagKey, ); }); - }); - describe("method resolveObjectEvaluation", () => { - it("should return the default value and an error message", async () => { - const defaultValue = { key: "value" }; - const result = await provider.resolveObjectEvaluation( - "number1", - defaultValue, - ); - expect(result.value).toEqual(defaultValue); - expect(result.errorCode).toEqual(ErrorCode.GENERAL); - expect(result.errorMessage).toEqual( - `Bucket doesn't support object flags`, - ); + + it.each([ + [true, false, true, "TARGETING_MATCH", undefined], + [undefined, true, true, "ERROR", "FLAG_NOT_FOUND"], + [undefined, false, false, "ERROR", "FLAG_NOT_FOUND"], + ])( + "should return the correct result when evaluating boolean. enabled: %s, value: %s, default: %s, expected: %s, reason: %s, errorCode: %s`", + async (enabled, def, expected, reason, errorCode) => { + const configKey = enabled !== undefined ? "variant-1" : undefined; + + mockFeature(enabled ?? false, configKey); + const flagKey = enabled ? testFlagKey : "missing-key"; + + expect( + await provider.resolveBooleanEvaluation(flagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(configKey ? { variant: configKey } : {}), + ...(errorCode ? { errorCode } : {}), + }); + }, + ); + + it("should return error when context is missing user ID", async () => { + mockTranslatorFn.mockReturnValue({ user: {} }); + + expect( + await provider.resolveBooleanEvaluation(testFlagKey, true, context), + ).toMatchObject({ + reason: "ERROR", + errorCode: "INVALID_CONTEXT", + value: true, + }); }); - }); - describe("onClose", () => { - it("calls flush", async () => { - await provider.onClose(); - expect(bucketClientMock.flush).toHaveBeenCalledTimes(1); + it("should return error when evaluating number", async () => { + expect( + await provider.resolveNumberEvaluation(testFlagKey, 1), + ).toMatchObject({ + reason: "ERROR", + errorCode: "GENERAL", + value: 1, + }); }); + + it.each([ + ["key-1", "default", "key-1", "TARGETING_MATCH"], + [null, "default", "default", "DEFAULT"], + [undefined, "default", "default", "DEFAULT"], + ])( + "should return the correct result when evaluating string. variant: %s, def: %s, expected: %s, reason: %s, errorCode: %s`", + async (variant, def, expected, reason) => { + mockFeature(true, variant, {}); + expect( + await provider.resolveStringEvaluation(testFlagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(variant ? { variant } : {}), + }); + }, + ); + + it.each([ + [{}, { a: 1 }, {}, "TARGETING_MATCH", undefined], + ["string", "default", "string", "TARGETING_MATCH", undefined], + [15, -15, 15, "TARGETING_MATCH", undefined], + [true, false, true, "TARGETING_MATCH", undefined], + [null, { a: 2 }, { a: 2 }, "ERROR", "TYPE_MISMATCH"], + [100, "string", "string", "ERROR", "TYPE_MISMATCH"], + [true, 1337, 1337, "ERROR", "TYPE_MISMATCH"], + ["string", 1337, 1337, "ERROR", "TYPE_MISMATCH"], + [undefined, "default", "default", "ERROR", "TYPE_MISMATCH"], + ])( + "should return the correct result when evaluating object. payload: %s, default: %s, expected: %s, reason: %s, errorCode: %s`", + async (value, def, expected, reason, errorCode) => { + const configKey = value === undefined ? undefined : "config-key"; + mockFeature(true, configKey, value); + expect( + await provider.resolveObjectEvaluation(testFlagKey, def, context), + ).toMatchObject({ + reason, + value: expected, + ...(errorCode ? { errorCode, variant: configKey } : {}), + }); + }, + ); }); describe("track", () => { it("should track", async () => { - expect(translatorFn).toHaveBeenCalledTimes(0); + expect(mockTranslatorFn).toHaveBeenCalledTimes(0); provider.track("event", context, { action: "click", }); - expect(translatorFn).toHaveBeenCalledTimes(1); - expect(translatorFn).toHaveBeenCalledWith(context); + + expect(mockTranslatorFn).toHaveBeenCalledTimes(1); + expect(mockTranslatorFn).toHaveBeenCalledWith(context); expect(bucketClientMock.track).toHaveBeenCalledTimes(1); expect(bucketClientMock.track).toHaveBeenCalledWith("42", "event", { attributes: { action: "click" }, diff --git a/packages/openfeature-node-provider/src/index.ts b/packages/openfeature-node-provider/src/index.ts index 7f2f512a..ad771095 100644 --- a/packages/openfeature-node-provider/src/index.ts +++ b/packages/openfeature-node-provider/src/index.ts @@ -21,17 +21,22 @@ type ProviderOptions = ClientOptions & { contextTranslator?: (context: EvaluationContext) => BucketContext; }; -const defaultTranslator = (context: EvaluationContext): BucketContext => { +export const defaultContextTranslator = ( + context: EvaluationContext, +): BucketContext => { const user = { - id: context.targetingKey ?? context["id"]?.toString(), + id: context.targetingKey ?? context["userId"]?.toString(), name: context["name"]?.toString(), email: context["email"]?.toString(), + avatar: context["avatar"]?.toString(), country: context["country"]?.toString(), }; const company = { id: context["companyId"]?.toString(), name: context["companyName"]?.toString(), + avatar: context["companyAvatar"]?.toString(), + plan: context["companyPlan"]?.toString(), }; return { @@ -61,7 +66,7 @@ export class BucketNodeProvider implements Provider { constructor({ contextTranslator, ...opts }: ProviderOptions) { this._client = new BucketClient(opts); - this.contextTranslator = contextTranslator ?? defaultTranslator; + this.contextTranslator = contextTranslator ?? defaultContextTranslator; } public async initialize(): Promise { @@ -69,42 +74,90 @@ export class BucketNodeProvider implements Provider { this.status = ServerProviderStatus.READY; } - resolveBooleanEvaluation( + private resolveFeature( flagKey: string, - defaultValue: boolean, - context: EvaluationContext, - ): Promise> { - const features = this._client.getFeatures(this.contextTranslator(context)); + defaultValue: T, + context: BucketContext, + resolveFn: ( + feature: ReturnType, + ) => Promise>, + ): Promise> { + if (this.status !== ServerProviderStatus.READY) { + return Promise.resolve({ + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.PROVIDER_NOT_READY, + errorMessage: "Bucket client not initialized", + }); + } - const feature = features[flagKey]; - if (!feature) { + if (!context.user?.id) { return Promise.resolve({ value: defaultValue, - source: "bucket-node", - flagKey, - errorCode: ErrorCode.FLAG_NOT_FOUND, reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.INVALID_CONTEXT, + errorMessage: "At least a user ID is required", }); } + const features = this._client.getFeatures(context); + if (flagKey in features) { + return resolveFn(this._client.getFeature(context, flagKey)); + } + return Promise.resolve({ - value: feature.isEnabled, - source: "bucket-node", - flagKey, - reason: StandardResolutionReasons.TARGETING_MATCH, + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: `Flag ${flagKey} not found`, }); } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + ): Promise> { + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + return Promise.resolve({ + value: feature.isEnabled, + variant: feature.config?.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); + } + resolveStringEvaluation( - _flagKey: string, + flagKey: string, defaultValue: string, + context: EvaluationContext, ): Promise> { - return Promise.resolve({ - value: defaultValue, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support string flags", - }); + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + if (!feature.config.key) { + return Promise.resolve({ + value: defaultValue, + reason: StandardResolutionReasons.DEFAULT, + }); + } + + return Promise.resolve({ + value: feature.config.key as string, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); } + resolveNumberEvaluation( _flagKey: string, defaultValue: number, @@ -113,19 +166,45 @@ export class BucketNodeProvider implements Provider { value: defaultValue, reason: StandardResolutionReasons.ERROR, errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support number flags", + errorMessage: + "Bucket doesn't support this method. Use `resolveObjectEvaluation` instead.", }); } + resolveObjectEvaluation( - _flagKey: string, + flagKey: string, defaultValue: T, + context: EvaluationContext, ): Promise> { - return Promise.resolve({ - value: defaultValue, - reason: StandardResolutionReasons.ERROR, - errorCode: ErrorCode.GENERAL, - errorMessage: "Bucket doesn't support object flags", - }); + return this.resolveFeature( + flagKey, + defaultValue, + this.contextTranslator(context), + (feature) => { + const expType = typeof defaultValue; + const payloadType = typeof feature.config.payload; + + if ( + feature.config.payload === undefined || + feature.config.payload === null || + payloadType !== expType + ) { + return Promise.resolve({ + value: defaultValue, + variant: feature.config.key, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: `Expected remote config payload of type \`${expType}\` but got \`${payloadType}\`.`, + }); + } + + return Promise.resolve({ + value: feature.config.payload, + variant: feature.config.key, + reason: StandardResolutionReasons.TARGETING_MATCH, + }); + }, + ); } track( diff --git a/yarn.lock b/yarn.lock index 7f1ce407..259b2b5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -948,12 +948,12 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/node-sdk@npm:>=1.4.2": - version: 1.5.0 - resolution: "@bucketco/node-sdk@npm:1.5.0" +"@bucketco/node-sdk@npm:1.6.0-alpha.3": + version: 1.6.0-alpha.3 + resolution: "@bucketco/node-sdk@npm:1.6.0-alpha.3" dependencies: "@bucketco/flag-evaluation": "npm:~0.1.0" - checksum: 10c0/63230400c0c0fa6ccf8708550bbcf583cc58bd18a2b99e19ec1dde43bce593c43136790ff3f0573f171c123c6a0555eebafcefdfa5cc71a2e706079fdb1ebe39 + checksum: 10c0/7f9654168a3a94b7971d1d2ca0736f9ea1fd1f68d2f170b3eccc345eeab34a1bcb5da7973a63b38b4f0fb8df1353143d87d24c85bca2d52fe6fc0aaf9f2a4951 languageName: node linkType: hard @@ -1007,7 +1007,7 @@ __metadata: dependencies: "@babel/core": "npm:~7.24.7" "@bucketco/eslint-config": "npm:~0.0.2" - "@bucketco/node-sdk": "npm:>=1.4.2" + "@bucketco/node-sdk": "npm:1.6.0-alpha.3" "@bucketco/tsconfig": "npm:~0.0.2" "@openfeature/core": "npm:^1.5.0" "@openfeature/server-sdk": "npm:>=1.16.1" From 33acb1d660c3ac037d880c8d1954d08195e5d618 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 12 Feb 2025 20:59:12 +0700 Subject: [PATCH 30/60] chore(deps): bump @bucketco/node-sdk from 1.6.0-alpha.3 to 1.6.0-alpha.4 Update node-sdk dependency to the latest alpha version in yarn.lock and package.json --- packages/openfeature-node-provider/package.json | 2 +- yarn.lock | 13 ++----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json index 5cf1a21a..c3ded48c 100644 --- a/packages/openfeature-node-provider/package.json +++ b/packages/openfeature-node-provider/package.json @@ -44,7 +44,7 @@ "vitest": "~1.6.0" }, "dependencies": { - "@bucketco/node-sdk": "1.6.0-alpha.3" + "@bucketco/node-sdk": "1.6.0-alpha.4" }, "peerDependencies": { "@openfeature/server-sdk": ">=1.16.1" diff --git a/yarn.lock b/yarn.lock index 259b2b5e..42109e1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -948,16 +948,7 @@ __metadata: languageName: unknown linkType: soft -"@bucketco/node-sdk@npm:1.6.0-alpha.3": - version: 1.6.0-alpha.3 - resolution: "@bucketco/node-sdk@npm:1.6.0-alpha.3" - dependencies: - "@bucketco/flag-evaluation": "npm:~0.1.0" - checksum: 10c0/7f9654168a3a94b7971d1d2ca0736f9ea1fd1f68d2f170b3eccc345eeab34a1bcb5da7973a63b38b4f0fb8df1353143d87d24c85bca2d52fe6fc0aaf9f2a4951 - languageName: node - linkType: hard - -"@bucketco/node-sdk@workspace:packages/node-sdk": +"@bucketco/node-sdk@npm:1.6.0-alpha.4, @bucketco/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@bucketco/node-sdk@workspace:packages/node-sdk" dependencies: @@ -1007,7 +998,7 @@ __metadata: dependencies: "@babel/core": "npm:~7.24.7" "@bucketco/eslint-config": "npm:~0.0.2" - "@bucketco/node-sdk": "npm:1.6.0-alpha.3" + "@bucketco/node-sdk": "npm:1.6.0-alpha.4" "@bucketco/tsconfig": "npm:~0.0.2" "@openfeature/core": "npm:^1.5.0" "@openfeature/server-sdk": "npm:>=1.16.1" From 8b63880db227700bd3b8f1d43388e515ecc30a8f Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Thu, 20 Feb 2025 20:02:39 +0700 Subject: [PATCH 31/60] feat(openfeature-node-provider): enhance todo feature management with config and validation (#330) --- .../openfeature-node-provider/example/app.ts | 30 ++++++++++++++----- .../example/bucket.ts | 20 ++++++++++++- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/openfeature-node-provider/example/app.ts b/packages/openfeature-node-provider/example/app.ts index ea5b72cf..fdfcdd8b 100644 --- a/packages/openfeature-node-provider/example/app.ts +++ b/packages/openfeature-node-provider/example/app.ts @@ -1,15 +1,15 @@ import express from "express"; import "./bucket"; import { EvaluationContext, OpenFeature } from "@openfeature/server-sdk"; -import provider from "./bucket"; +import provider, { CreateTodosConfig } from "./bucket"; -// In the following, we assume that targetingKey is a unique identifier for the user +// In the following, we assume that targetingKey is a unique identifier for the user. type Context = EvaluationContext & { targetingKey: string; companyId: string; }; -// Augment the Express types to include the some context property on the `res.locals` object +// Augment the Express types to include the some context property on the `res.locals` object. declare global { namespace Express { interface Locals { @@ -36,6 +36,7 @@ const todos = ["Buy milk", "Walk the dog"]; app.get("/", (_req, res) => { const ofClient = OpenFeature.getClient(); ofClient.track("front-page-viewed", res.locals.context); + res.json({ message: "Ready to manage some TODOs!" }); }); @@ -46,7 +47,7 @@ app.get("/todos", async (req, res) => { // and that the indexing for feature name below is type-checked at compile time. const ofClient = OpenFeature.getClient(); const isEnabled = await ofClient.getBooleanValue( - "show-todo", + "show-todos", false, res.locals.context, ); @@ -75,10 +76,23 @@ app.post("/todos", async (req, res) => { res.locals.context, ); - // Check if the user has the "create-todos" feature enabled + // Check if the user has the "create-todos" feature enabled. if (isEnabled) { + // Get the configuration for the "create-todos" feature. + // We expect the configuration to be a JSON object with a `maxLength` property. + const config = await ofClient.getObjectValue( + "create-todos", + { maxLength: 100 }, + res.locals.context, + ); + + // Check if the todo is too long. + if (todo.length > config.maxLength) { + return res.status(400).json({ error: "Todo is too long" }); + } + // Track the feature usage - ofClient.track("create-todo", res.locals.context); + ofClient.track("create-todos", res.locals.context); todos.push(todo); return res.status(201).json({ todo }); @@ -98,7 +112,7 @@ app.delete("/todos/:idx", async (req, res) => { const ofClient = OpenFeature.getClient(); const isEnabled = await ofClient.getBooleanValue( - "delete-todo", + "delete-todos", false, res.locals.context, ); @@ -106,7 +120,7 @@ app.delete("/todos/:idx", async (req, res) => { if (isEnabled) { todos.splice(idx, 1); - ofClient.track("delete-todo", res.locals.context); + ofClient.track("delete-todos", res.locals.context); return res.json({}); } diff --git a/packages/openfeature-node-provider/example/bucket.ts b/packages/openfeature-node-provider/example/bucket.ts index 3462a72b..1121ee00 100644 --- a/packages/openfeature-node-provider/example/bucket.ts +++ b/packages/openfeature-node-provider/example/bucket.ts @@ -5,11 +5,29 @@ if (!process.env.BUCKET_SECRET_KEY) { throw new Error("BUCKET_SECRET_KEY is required"); } +export type CreateTodosConfig = { + maxLength: number; +}; + const provider = new BucketNodeProvider({ secretKey: process.env.BUCKET_SECRET_KEY!, - fallbackFeatures: ["show-todos"], + fallbackFeatures: { + "show-todos": { + isEnabled: true, + }, + "create-todos": { + isEnabled: true, + config: { + key: "default", + payload: { + maxLength: 100, + }, + }, + }, + }, logger: console, }); + OpenFeature.setProvider(provider); export default provider; From 99eb2c875d23e44af23036b18889513976e03958 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Thu, 13 Feb 2025 06:46:58 +0100 Subject: [PATCH 32/60] feat(node-sdk): add configurable exit event flushing (#311) This PR Introduces `flushOnExit` option in batch buffer configuration. When on (default), the client will flush all events on it's exit. --- packages/node-sdk/README.md | 16 +- packages/node-sdk/example/bucket.ts | 5 - packages/node-sdk/example/serve.ts | 2 + packages/node-sdk/src/client.ts | 11 ++ packages/node-sdk/src/config.ts | 1 + packages/node-sdk/src/flusher.ts | 62 ++++++++ packages/node-sdk/src/types.ts | 11 ++ packages/node-sdk/src/utils.ts | 41 +++++ packages/node-sdk/test/client.test.ts | 48 ++++++ packages/node-sdk/test/flusher.test.ts | 210 +++++++++++++++++++++++++ packages/node-sdk/test/utils.test.ts | 95 ++++++++++- 11 files changed, 490 insertions(+), 12 deletions(-) create mode 100644 packages/node-sdk/src/flusher.ts create mode 100644 packages/node-sdk/test/flusher.test.ts diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 2738b958..263fbbde 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -604,17 +604,21 @@ the number of calls that are sent to Bucket's servers. During process shutdown, some messages could be waiting to be sent, and thus, would be discarded if the buffer is not flushed. -A naive example: +By default, the SDK automatically subscribes to process exit signals and attempts to flush +any pending events. This behavior is controlled by the `flushOnExit` option in the client configuration: ```typescript -process.on("SIGINT", () => { - console.log("Flushing batch buffer..."); - client.flush().then(() => { - process.exit(0); - }); +const client = new BucketClient({ + batchOptions: { + flushOnExit: false, // disable automatic flushing on exit + }, }); ``` +> [!NOTE] +> If you are creating multiple client instances in your application, it's recommended to disable `flushOnExit` +> to avoid potential conflicts during process shutdown. In such cases, you should implement your own flush handling. + When you bind a client to a user/company, this data is matched against the targeting rules. To get accurate targeting, you must ensure that the user/company information provided is sufficient to match against the targeting rules you've diff --git a/packages/node-sdk/example/bucket.ts b/packages/node-sdk/example/bucket.ts index 907c4deb..87e4ab8a 100644 --- a/packages/node-sdk/example/bucket.ts +++ b/packages/node-sdk/example/bucket.ts @@ -35,11 +35,6 @@ let featureOverrides = (_: Context): FeatureOverrides => { }; // feature keys checked at compile time }; -let host = undefined; -if (process.env.BUCKET_HOST) { - host = process.env.BUCKET_HOST; -} - // Create a new BucketClient instance with the secret key and default features // The default features will be used if the user does not have any features set // Create a bucketConfig.json file to configure the client or set environment variables diff --git a/packages/node-sdk/example/serve.ts b/packages/node-sdk/example/serve.ts index b4abd9d9..ada781f5 100644 --- a/packages/node-sdk/example/serve.ts +++ b/packages/node-sdk/example/serve.ts @@ -1,3 +1,5 @@ +import "dotenv/config"; + import bucket from "./bucket"; import app from "./app"; diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index a89ebdbd..a09c123d 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -14,6 +14,7 @@ import { SDK_VERSION_HEADER_NAME, } from "./config"; import fetchClient from "./fetch-http-client"; +import { subscribe as triggerOnExit } from "./flusher"; import { newRateLimiter } from "./rate-limiter"; import type { EvaluatedFeaturesAPIResponse, @@ -266,6 +267,10 @@ export class BucketClient { : () => config.featureOverrides, }; + if ((config.batchOptions?.flushOnExit ?? true) && !this._config.offline) { + triggerOnExit(() => this.flush()); + } + if (!new URL(this._config.apiBaseUrl).pathname.endsWith("/")) { this._config.apiBaseUrl += "/"; } @@ -470,8 +475,14 @@ export class BucketClient { * @remarks * It is recommended to call this method when the application is shutting down to ensure all events are sent * before the process exits. + * + * This method is automatically called when the process exits if `batchOptions.flushOnExit` is `true` in the options (default). */ public async flush() { + if (this._config.offline) { + return; + } + await this._config.batchBuffer.flush(); } diff --git a/packages/node-sdk/src/config.ts b/packages/node-sdk/src/config.ts index b6ec95e2..eb4ece38 100644 --- a/packages/node-sdk/src/config.ts +++ b/packages/node-sdk/src/config.ts @@ -9,6 +9,7 @@ export const API_BASE_URL = "https://front.bucket.co"; export const SDK_VERSION_HEADER_NAME = "bucket-sdk-version"; export const SDK_VERSION = `node-sdk/${version}`; export const API_TIMEOUT_MS = 5000; +export const END_FLUSH_TIMEOUT_MS = 5000; export const BUCKET_LOG_PREFIX = "[Bucket]"; diff --git a/packages/node-sdk/src/flusher.ts b/packages/node-sdk/src/flusher.ts new file mode 100644 index 00000000..47e4937d --- /dev/null +++ b/packages/node-sdk/src/flusher.ts @@ -0,0 +1,62 @@ +import { constants } from "os"; + +import { END_FLUSH_TIMEOUT_MS } from "./config"; +import { TimeoutError, withTimeout } from "./utils"; + +type Callback = () => Promise; + +const killSignals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"] as const; + +export function subscribe( + callback: Callback, + timeout: number = END_FLUSH_TIMEOUT_MS, +) { + let state: boolean | undefined; + + const wrappedCallback = async () => { + if (state !== undefined) { + return; + } + + state = false; + + try { + await withTimeout(callback(), timeout); + } catch (error) { + if (error instanceof TimeoutError) { + console.error( + "[Bucket SDK] Timeout while flushing events on process exit.", + ); + } else { + console.error( + "[Bucket SDK] An error occurred while flushing events on process exit.", + error, + ); + } + } + + state = true; + }; + + killSignals.forEach((signal) => { + const hasListeners = process.listenerCount(signal) > 0; + + if (hasListeners) { + process.prependListener(signal, wrappedCallback); + } else { + process.on(signal, async () => { + await wrappedCallback(); + process.exit(0x80 + constants.signals[signal]); + }); + } + }); + + process.on("beforeExit", wrappedCallback); + process.on("exit", () => { + if (!state) { + console.error( + "[Bucket SDK] Failed to finalize the flushing of events on process exit.", + ); + } + }); +} diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 1cb21f20..5d448b87 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -455,13 +455,24 @@ export type BatchBufferOptions = { /** * The maximum size of the buffer before it is flushed. + * + * @defaultValue `100` **/ maxSize?: number; /** * The interval in milliseconds at which the buffer is flushed. + * + * @defaultValue `1000` **/ intervalMs?: number; + + /** + * Whether to flush the buffer on exit. + * + * @defaultValue `true` + */ + flushOnExit?: boolean; }; /** diff --git a/packages/node-sdk/src/utils.ts b/packages/node-sdk/src/utils.ts index e2ec10c9..150714da 100644 --- a/packages/node-sdk/src/utils.ts +++ b/packages/node-sdk/src/utils.ts @@ -175,3 +175,44 @@ export function once ReturnType>( return returned; }; } + +export class TimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Operation timed out after ${timeoutMs}ms`); + this.name = "TimeoutError"; + } +} + +/** + * Wraps a promise with a timeout. If the promise doesn't resolve within the specified + * timeout, it will reject with a timeout error. The original promise will still + * continue to execute but its result will be ignored. + * + * @param promise - The promise to wrap with a timeout + * @param timeoutMs - The timeout in milliseconds + * @returns A promise that resolves with the original promise result or rejects with a timeout error + * @throws {Error} If the timeout is reached before the promise resolves + **/ +export function withTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + ok(timeoutMs > 0, "timeout must be a positive number"); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new TimeoutError(timeoutMs)); + }, timeoutMs); + + promise + .then((result) => { + resolve(result); + }) + .catch((error) => { + reject(error); + }) + .finally(() => { + clearTimeout(timeoutId); + }); + }); +} diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 696217cc..7ca7613f 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -23,6 +23,7 @@ import { SDK_VERSION_HEADER_NAME, } from "../src/config"; import fetchClient from "../src/fetch-http-client"; +import { subscribe as triggerOnExit } from "../src/flusher"; import { newRateLimiter } from "../src/rate-limiter"; import { ClientOptions, Context, FeaturesAPIResponse } from "../src/types"; @@ -45,6 +46,10 @@ vi.mock("../src/rate-limiter", async (importOriginal) => { }; }); +vi.mock("../src/flusher", () => ({ + subscribe: vi.fn(), +})); + const user = { id: "user123", age: 1, @@ -82,6 +87,7 @@ const validOptions: ClientOptions = { batchOptions: { maxSize: 99, intervalMs: 100, + flushOnExit: false, }, offline: false, }; @@ -372,6 +378,36 @@ describe("BucketClient", () => { ); }); + it("should not register an exit flush handler if `batchOptions.flushOnExit` is false", () => { + new BucketClient({ + ...validOptions, + batchOptions: { ...validOptions.batchOptions, flushOnExit: false }, + }); + + expect(triggerOnExit).not.toHaveBeenCalled(); + }); + + it("should not register an exit flush handler if `offline` is true", () => { + new BucketClient({ + ...validOptions, + offline: true, + }); + + expect(triggerOnExit).not.toHaveBeenCalled(); + }); + + it.each([undefined, true])( + "should register an exit flush handler if `batchOptions.flushOnExit` is `%s`", + (flushOnExit) => { + new BucketClient({ + ...validOptions, + batchOptions: { ...validOptions.batchOptions, flushOnExit }, + }); + + expect(triggerOnExit).toHaveBeenCalledWith(expect.any(Function)); + }, + ); + it.each([ ["https://api.example.com", "https://api.example.com/bulk"], ["https://api.example.com/", "https://api.example.com/bulk"], @@ -971,6 +1007,18 @@ describe("BucketClient", () => { ], ); }); + + it("should not flush all bulk data if `offline` is true", async () => { + const client = new BucketClient({ + ...validOptions, + offline: true, + }); + + await client.updateUser(user.id, { attributes: { age: 2 } }); + await client.flush(); + + expect(httpClient.post).not.toHaveBeenCalled(); + }); }); describe("getFeature", () => { diff --git a/packages/node-sdk/test/flusher.test.ts b/packages/node-sdk/test/flusher.test.ts new file mode 100644 index 00000000..e6b09511 --- /dev/null +++ b/packages/node-sdk/test/flusher.test.ts @@ -0,0 +1,210 @@ +import { constants } from "os"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + MockInstance, + vi, +} from "vitest"; + +import { subscribe } from "../src/flusher"; + +describe("flusher", () => { + const mockExit = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as any); + + const mockConsoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + const mockProcessOn = vi + .spyOn(process, "on") + .mockImplementation((_, __) => process); + + const mockProcessPrependListener = ( + vi.spyOn(process, "prependListener") as unknown as MockInstance< + [event: NodeJS.Signals, listener: NodeJS.SignalsListener], + NodeJS.Process + > + ).mockImplementation((_, __) => process); + + const mockListenerCount = vi + .spyOn(process, "listenerCount") + .mockReturnValue(0); + + function timedCallback(ms: number) { + return vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + ); + } + + function getHandler(eventName: string, prepended = false) { + return prepended + ? mockProcessPrependListener.mock.calls.filter( + ([evt]) => evt === eventName, + )[0][1] + : mockProcessOn.mock.calls.filter(([evt]) => evt === eventName)[0][1]; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + describe("signal handling", () => { + const signals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"] as const; + + describe.each(signals)("signal %s", (signal) => { + it("should handle signal with no existing listeners", async () => { + mockListenerCount.mockReturnValue(0); + const callback = vi.fn().mockResolvedValue(undefined); + + subscribe(callback); + expect(mockProcessOn).toHaveBeenCalledWith( + signal, + expect.any(Function), + ); + + getHandler(signal)(signal); + await vi.runAllTimersAsync(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(mockExit).toHaveBeenCalledWith(0x80 + constants.signals[signal]); + }); + + it("should prepend handler when listeners exist", async () => { + mockListenerCount.mockReturnValue(1); + const callback = vi.fn().mockResolvedValue(undefined); + + subscribe(callback); + + expect(mockProcessPrependListener).toHaveBeenCalledWith( + signal, + expect.any(Function), + ); + + getHandler(signal, true)(signal); + + expect(callback).toHaveBeenCalledTimes(1); + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + }); + + describe("beforeExit handling", () => { + it("should call callback on beforeExit", async () => { + const callback = vi.fn().mockResolvedValue(undefined); + + subscribe(callback); + + getHandler("beforeExit")(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should not call callback multiple times", async () => { + const callback = vi.fn().mockResolvedValue(undefined); + + subscribe(callback); + + getHandler("beforeExit")(); + getHandler("beforeExit")(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("timeout handling", () => { + it("should handle timeout when callback takes too long", async () => { + subscribe(timedCallback(2000), 1000); + + getHandler("beforeExit")(); + + await vi.advanceTimersByTimeAsync(1000); + + expect(mockConsoleError).toHaveBeenCalledWith( + "[Bucket SDK] Timeout while flushing events on process exit.", + ); + }); + + it("should not timeout when callback completes in time", async () => { + subscribe(timedCallback(500), 1000); + + getHandler("beforeExit")(); + await vi.advanceTimersByTimeAsync(500); + + expect(mockConsoleError).not.toHaveBeenCalled(); + }); + }); + + describe("exit state handling", () => { + it("should log error if exit occurs before flushing starts", () => { + subscribe(timedCallback(0)); + + getHandler("exit")(); + + expect(mockConsoleError).toHaveBeenCalledWith( + "[Bucket SDK] Failed to finalize the flushing of events on process exit.", + ); + }); + + it("should log error if exit occurs before flushing completes", async () => { + subscribe(timedCallback(2000)); + getHandler("beforeExit")(); + + await vi.advanceTimersByTimeAsync(1000); + + getHandler("exit")(); + + expect(mockConsoleError).toHaveBeenCalledWith( + "[Bucket SDK] Failed to finalize the flushing of events on process exit.", + ); + }); + + it("should not log error if flushing completes before exit", async () => { + subscribe(timedCallback(500)); + + getHandler("beforeExit")(); + await vi.advanceTimersByTimeAsync(500); + + getHandler("exit")(); + + expect(mockConsoleError).not.toHaveBeenCalled(); + }); + + it("should handle callback errors gracefully", async () => { + subscribe(vi.fn().mockRejectedValue(new Error("Test error"))); + + getHandler("beforeExit")(); + await vi.runAllTimersAsync(); + + expect(mockConsoleError).toHaveBeenCalledWith( + "[Bucket SDK] An error occurred while flushing events on process exit.", + expect.any(Error), + ); + }); + }); + + it("should run the callback only once", async () => { + const callback = vi.fn().mockResolvedValue(undefined); + + subscribe(callback); + + getHandler("SIGINT")("SIGINT"); + getHandler("beforeExit")(); + + await vi.runAllTimersAsync(); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/node-sdk/test/utils.test.ts b/packages/node-sdk/test/utils.test.ts index 0ef0e5be..43fc90ae 100644 --- a/packages/node-sdk/test/utils.test.ts +++ b/packages/node-sdk/test/utils.test.ts @@ -1,5 +1,5 @@ import { createHash } from "crypto"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { decorateLogger, @@ -8,6 +8,8 @@ import { mergeSkipUndefined, ok, once, + TimeoutError, + withTimeout, } from "../src/utils"; describe("isObject", () => { @@ -205,3 +207,94 @@ describe("once()", () => { expect(fn).toHaveBeenCalledTimes(1); }); }); + +describe("withTimeout()", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should resolve when promise completes before timeout", async () => { + const promise = Promise.resolve("success"); + const result = withTimeout(promise, 1000); + + await expect(result).resolves.toBe("success"); + }); + + it("should reject with TimeoutError when promise takes too long", async () => { + const slowPromise = new Promise((resolve) => { + setTimeout(() => resolve("too late"), 2000); + }); + + const result = withTimeout(slowPromise, 1000); + + vi.advanceTimersByTime(1000); + + await expect(result).rejects.toThrow("Operation timed out after 1000ms"); + await expect(result).rejects.toBeInstanceOf(TimeoutError); + }); + + it("should propagate original promise rejection", async () => { + const error = new Error("original error"); + const failedPromise = Promise.reject(error); + + const result = withTimeout(failedPromise, 1000); + + await expect(result).rejects.toBe(error); + }); + + it("should reject immediately for negative timeout", async () => { + const promise = Promise.resolve("success"); + + await expect(async () => { + await withTimeout(promise, -1); + }).rejects.toThrow("validation failed: timeout must be a positive number"); + }); + + it("should reject immediately for zero timeout", async () => { + const promise = Promise.resolve("success"); + + await expect(async () => { + await withTimeout(promise, 0); + }).rejects.toThrow("validation failed: timeout must be a positive number"); + }); + + it("should clean up timeout when promise resolves", async () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + const promise = Promise.resolve("success"); + + await withTimeout(promise, 1000); + await vi.runAllTimersAsync(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + it("should clean up timeout when promise rejects", async () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + const promise = Promise.reject(new Error("fail")); + + await expect(withTimeout(promise, 1000)).rejects.toThrow("fail"); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + it("should not resolve after timeout occurs", async () => { + const slowPromise = new Promise((resolve) => { + setTimeout(() => resolve("too late"), 2000); + }); + + const result = withTimeout(slowPromise, 1000); + + vi.advanceTimersByTime(1000); // Trigger timeout + await expect(result).rejects.toThrow("Operation timed out after 1000ms"); + + vi.advanceTimersByTime(1000); // Complete the original promise + // The promise should still be rejected with the timeout error + await expect(result).rejects.toThrow("Operation timed out after 1000ms"); + }); +}); From b504803a7a3c1571a99e607481aba4b81c32044e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Feb 2025 06:25:14 +0000 Subject: [PATCH 33/60] chore(deps): bump serialize-javascript from 6.0.1 to 6.0.2 (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) from 6.0.1 to 6.0.2.
Release notes

Sourced from serialize-javascript's releases.

v6.0.2

  • fix: serialize URL string contents to prevent XSS (#173) f27d65d
  • Bump @​babel/traverse from 7.10.1 to 7.23.7 (#171) 02499c0
  • docs: update readme with URL support (#146) 0d88527
  • chore: update node version and lock file e2a3a91
  • fix typo (#164) 5a1fa64

https://github.com/yahoo/serialize-javascript/compare/v6.0.1...v6.0.2

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=serialize-javascript&package-manager=npm_and_yarn&previous-version=6.0.1&new-version=6.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/bucketco/bucket-javascript-sdk/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 42109e1c..0e46d833 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13745,11 +13745,11 @@ __metadata: linkType: hard "serialize-javascript@npm:^6.0.1": - version: 6.0.1 - resolution: "serialize-javascript@npm:6.0.1" + version: 6.0.2 + resolution: "serialize-javascript@npm:6.0.2" dependencies: randombytes: "npm:^2.1.0" - checksum: 10c0/1af427f4fee3fee051f54ffe15f77068cff78a3c96d20f5c1178d20630d3ab122d8350e639d5e13cde8111ef9db9439b871305ffb185e24be0a2149cec230988 + checksum: 10c0/2dd09ef4b65a1289ba24a788b1423a035581bef60817bea1f01eda8e3bda623f86357665fe7ac1b50f6d4f583f97db9615b3f07b2a2e8cbcb75033965f771dd2 languageName: node linkType: hard From 974fa63dfc1b689a4af5f555974263f1f166ad7a Mon Sep 17 00:00:00 2001 From: Matus Vacula Date: Thu, 13 Feb 2025 10:56:42 +0100 Subject: [PATCH 34/60] chore: upgrade typedoc-plugin-markdown (#320) New version has a fix I made for links to project documents. https://github.com/typedoc2md/typedoc-plugin-markdown/blob/main/packages/typedoc-plugin-markdown/CHANGELOG.md#442 --- package.json | 2 +- yarn.lock | 228 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 222 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 050c213f..14ef3656 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "prettier": "^3.3.3", "typedoc": "0.27.6", "typedoc-plugin-frontmatter": "^1.1.2", - "typedoc-plugin-markdown": "^4.4.1", + "typedoc-plugin-markdown": "^4.4.2", "typedoc-plugin-mdn-links": "^4.0.7", "typescript": "^5.7.3" } diff --git a/yarn.lock b/yarn.lock index 0e46d833..897fbd9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -884,7 +884,7 @@ __metadata: languageName: node linkType: hard -"@bucketco/browser-sdk@npm:2.5.2, @bucketco/browser-sdk@workspace:packages/browser-sdk": +"@bucketco/browser-sdk@npm:3.0.0-alpha.2, @bucketco/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -1020,7 +1020,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/react-sdk@workspace:packages/react-sdk" dependencies: - "@bucketco/browser-sdk": "npm:2.5.2" + "@bucketco/browser-sdk": "npm:3.0.0-alpha.2" "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@testing-library/react": "npm:^15.0.7" @@ -1036,6 +1036,7 @@ __metadata: prettier: "npm:^3.3.3" react: "npm:*" react-dom: "npm:*" + rollup: "npm:^4.2.0" rollup-preserve-directives: "npm:^1.1.2" ts-node: "npm:^10.9.2" typescript: "npm:^5.7.3" @@ -2779,6 +2780,13 @@ __metadata: languageName: node linkType: hard +"@openfeature/core@npm:1.3.0": + version: 1.3.0 + resolution: "@openfeature/core@npm:1.3.0" + checksum: 10c0/48760b65d259d73d80ed5b3e03d5f4f604dfbe4a86561c0fb9c1b56d8a659ddead3c60260259ddca50d70c82d5dc181da5499d8a129b7bdcfeec0892e9865a0c + languageName: node + linkType: hard + "@openfeature/core@npm:1.5.0, @openfeature/core@npm:^1.5.0": version: 1.5.0 resolution: "@openfeature/core@npm:1.5.0" @@ -2952,6 +2960,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.34.8" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-android-arm64@npm:4.21.3" @@ -2966,6 +2981,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm64@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-android-arm64@npm:4.34.8" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-arm64@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-darwin-arm64@npm:4.21.3" @@ -2980,6 +3002,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-arm64@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-darwin-arm64@npm:4.34.8" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-x64@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-darwin-x64@npm:4.21.3" @@ -2994,6 +3023,27 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-x64@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-darwin-x64@npm:4.34.8" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.34.8" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-freebsd-x64@npm:4.34.8" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-gnueabihf@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.21.3" @@ -3008,6 +3058,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-gnueabihf@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.34.8" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-musleabihf@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.21.3" @@ -3022,6 +3079,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-musleabihf@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.34.8" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.21.3" @@ -3036,6 +3100,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-gnu@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.34.8" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-musl@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.21.3" @@ -3050,6 +3121,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.34.8" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loongarch64-gnu@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.34.8" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-powerpc64le-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.21.3" @@ -3064,6 +3149,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-powerpc64le-gnu@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.34.8" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.21.3" @@ -3078,6 +3170,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-gnu@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.34.8" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-s390x-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.21.3" @@ -3092,6 +3191,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-s390x-gnu@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.34.8" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.21.3" @@ -3106,6 +3212,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.34.8" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-linux-x64-musl@npm:4.21.3" @@ -3120,6 +3233,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.34.8" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.21.3" @@ -3134,6 +3254,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-arm64-msvc@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.34.8" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-ia32-msvc@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.21.3" @@ -3148,6 +3275,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.34.8" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.21.3": version: 4.21.3 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.21.3" @@ -3162,6 +3296,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.34.8": + version: 4.34.8 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.34.8" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rushstack/eslint-patch@npm:^1.3.3": version: 1.10.3 resolution: "@rushstack/eslint-patch@npm:1.10.3" @@ -10981,6 +11122,7 @@ __metadata: resolution: "nextjs-openfeature-example@workspace:packages/openfeature-browser-provider/example" dependencies: "@bucketco/react-sdk": "workspace:^" + "@openfeature/core": "npm:1.3.0" "@openfeature/react-sdk": "npm:^0.4.5" "@openfeature/web-sdk": "npm:^1.2.3" "@types/node": "npm:^22.12.0" @@ -13490,6 +13632,78 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^4.2.0": + version: 4.34.8 + resolution: "rollup@npm:4.34.8" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.34.8" + "@rollup/rollup-android-arm64": "npm:4.34.8" + "@rollup/rollup-darwin-arm64": "npm:4.34.8" + "@rollup/rollup-darwin-x64": "npm:4.34.8" + "@rollup/rollup-freebsd-arm64": "npm:4.34.8" + "@rollup/rollup-freebsd-x64": "npm:4.34.8" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.34.8" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.34.8" + "@rollup/rollup-linux-arm64-gnu": "npm:4.34.8" + "@rollup/rollup-linux-arm64-musl": "npm:4.34.8" + "@rollup/rollup-linux-loongarch64-gnu": "npm:4.34.8" + "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.34.8" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.34.8" + "@rollup/rollup-linux-s390x-gnu": "npm:4.34.8" + "@rollup/rollup-linux-x64-gnu": "npm:4.34.8" + "@rollup/rollup-linux-x64-musl": "npm:4.34.8" + "@rollup/rollup-win32-arm64-msvc": "npm:4.34.8" + "@rollup/rollup-win32-ia32-msvc": "npm:4.34.8" + "@rollup/rollup-win32-x64-msvc": "npm:4.34.8" + "@types/estree": "npm:1.0.6" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loongarch64-gnu": + optional: true + "@rollup/rollup-linux-powerpc64le-gnu": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/b9e711e33413112fbb761107c3fddc4561dfc74335c393542a829a85ccfb2763bfd17bf2422d84a2e9bee7646e5367018973e97005fdf64e49c2e209612f0eb6 + languageName: node + linkType: hard + "rollup@npm:^4.20.0": version: 4.21.3 resolution: "rollup@npm:4.21.3" @@ -15091,12 +15305,12 @@ __metadata: languageName: node linkType: hard -"typedoc-plugin-markdown@npm:^4.4.1": - version: 4.4.1 - resolution: "typedoc-plugin-markdown@npm:4.4.1" +"typedoc-plugin-markdown@npm:^4.4.2": + version: 4.4.2 + resolution: "typedoc-plugin-markdown@npm:4.4.2" peerDependencies: typedoc: 0.27.x - checksum: 10c0/54c9a25aed64d07258033c4d060acac15a618ee0494cbb2bc70fd10d03c82b3434715b6db01fbeb09d672cff736340666d70da1be83188e3a994048a0a0c6b65 + checksum: 10c0/93112f0f06f1c0bc7eec1ba7f9034b88a6817a92ec41e491e8ac73c23a5fbda84df537d136395295737499fc1d5afa94d1500a1921b1a967248dc5d3054fc9d6 languageName: node linkType: hard @@ -16111,7 +16325,7 @@ __metadata: prettier: "npm:^3.3.3" typedoc: "npm:0.27.6" typedoc-plugin-frontmatter: "npm:^1.1.2" - typedoc-plugin-markdown: "npm:^4.4.1" + typedoc-plugin-markdown: "npm:^4.4.2" typedoc-plugin-mdn-links: "npm:^4.0.7" typescript: "npm:^5.7.3" languageName: unknown From 37b6c57830702a4ba4b94b8d25b8471140814a56 Mon Sep 17 00:00:00 2001 From: Matus Vacula Date: Wed, 19 Feb 2025 10:43:04 +0100 Subject: [PATCH 35/60] chore: change docs destination (#327) --- .github/workflows/publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 43d9ceba..54de58ef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -39,12 +39,12 @@ jobs: path: bucket-docs - name: Copy generated docs to docs repo run: | - rm -rf bucket-docs/sdk-docs - cp -R dist/docs bucket-docs/sdk-docs + rm -rf bucket-docs/sdk + cp -R dist/docs bucket-docs/sdk - name: Commit and push changes run: | cd bucket-docs git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@bucket.co" - git add sdk-docs + git add sdk git commit -m "Update documentation" && git push || echo "No docs changes to commit" From b59088ac8619b12035995b565f9a47ad39a24d0a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 6 Feb 2025 13:46:14 +0100 Subject: [PATCH 36/60] refactor(browser-sdk,react-sdk): Remove deprecated and flatten options (#308) - standardize on `features` for passing a feaure list - take arguments from the old `features` options and put them in at the root options map. --- packages/browser-sdk/src/client.ts | 70 +++++++++++-------- packages/browser-sdk/src/feature/features.ts | 36 ++-------- packages/browser-sdk/src/index.ts | 1 - packages/browser-sdk/test/features.test.ts | 28 ++++---- .../nextjs-flag-demo/components/Providers.tsx | 2 +- packages/react-sdk/dev/plain/app.tsx | 4 +- packages/react-sdk/src/index.tsx | 62 +++++++++------- packages/react-sdk/test/usage.test.tsx | 37 +++++++--- 8 files changed, 124 insertions(+), 116 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index a3d34a19..7fde0e29 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -1,7 +1,7 @@ import { CheckEvent, + FallbackFeatureOverride, FeaturesClient, - FeaturesOptions, RawFeatures, } from "./feature/features"; import { @@ -185,7 +185,7 @@ export type FeatureDefinitions = Readonly>; /** * BucketClient initialization options. */ -export interface InitOptions { +export type InitOptions = { /** * Publishable key for authentication */ @@ -218,12 +218,6 @@ export interface InitOptions { */ logger?: Logger; - /** - * @deprecated - * Use `apiBaseUrl` instead. - */ - host?: string; - /** * Base URL of Bucket servers. You can override this to use your mocked server. */ @@ -235,10 +229,32 @@ export interface InitOptions { appBaseUrl?: string; /** - * @deprecated - * Use `sseBaseUrl` instead. + * Feature keys for which `isEnabled` should fallback to true + * 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[] | Record; + + /** + * Timeout in milliseconds when fetching features + */ + timeoutMs?: number; + + /** + * If set to true stale features will be returned while refetching features + */ + staleWhileRevalidate?: boolean; + + /** + * If set, features will be cached between page loads for this duration + */ + expireTimeMs?: number; + + /** + * Stale features will be returned if staleWhileRevalidate is true if no new features can be fetched */ - sseHost?: string; + staleTimeMs?: number; /** * Base URL of Bucket servers for SSE connections used by AutoFeedback. @@ -250,11 +266,6 @@ export interface InitOptions { */ feedback?: FeedbackOptions; - /** - * Feature flag specific configuration - */ - features?: FeaturesOptions; - /** * Version of the SDK */ @@ -266,17 +277,15 @@ export interface InitOptions { enableTracking?: boolean; /** - * Toolbar configuration (alpha) - * @ignore + * Toolbar configuration */ toolbar?: ToolbarOptions; /** - * Local-first definition of features (alpha) - * @ignore + * Local-first definition of features */ - featureList?: FeatureDefinitions; -} + features?: FeatureDefinitions; +}; const defaultConfig: Config = { apiBaseUrl: API_BASE_URL, @@ -335,7 +344,7 @@ function shouldShowToolbar(opts: InitOptions) { if (typeof toolbarOpts?.show === "boolean") return toolbarOpts.show; return ( - opts.featureList !== undefined && window?.location?.hostname === "localhost" + opts.features !== undefined && window?.location?.hostname === "localhost" ); } @@ -369,9 +378,9 @@ export class BucketClient { }; this.config = { - apiBaseUrl: opts?.apiBaseUrl ?? opts?.host ?? defaultConfig.apiBaseUrl, - appBaseUrl: opts?.appBaseUrl ?? opts?.host ?? defaultConfig.appBaseUrl, - sseBaseUrl: opts?.sseBaseUrl ?? opts?.sseHost ?? defaultConfig.sseBaseUrl, + apiBaseUrl: opts?.apiBaseUrl ?? defaultConfig.apiBaseUrl, + appBaseUrl: opts?.appBaseUrl ?? defaultConfig.appBaseUrl, + sseBaseUrl: opts?.sseBaseUrl ?? defaultConfig.sseBaseUrl, enableTracking: opts?.enableTracking ?? defaultConfig.enableTracking, }; @@ -395,9 +404,14 @@ export class BucketClient { company: this.context.company, other: this.context.otherContext, }, - opts?.featureList || [], + opts?.features || [], this.logger, - opts?.features, + { + expireTimeMs: opts.expireTimeMs, + staleTimeMs: opts.staleTimeMs, + fallbackFeatures: opts.fallbackFeatures, + timeoutMs: opts.timeoutMs, + }, ); if ( diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 7c11420a..cd536e5d 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -70,36 +70,6 @@ export type FallbackFeatureOverride = } | true; -export type FeaturesOptions = { - /** - * Feature keys for which `isEnabled` should fallback to true - * 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[] | Record; - - /** - * Timeout in milliseconds when fetching features - */ - timeoutMs?: number; - - /** - * If set to true stale features will be returned while refetching features - */ - staleWhileRevalidate?: boolean; - - /** - * If set, features will be cached between page loads for this duration - */ - expireTimeMs?: number; - - /** - * Stale features will be returned if staleWhileRevalidate is true if no new features can be fetched - */ - staleTimeMs?: number; -}; - type Config = { fallbackFeatures: Record; timeoutMs: number; @@ -219,7 +189,11 @@ export class FeaturesClient { private context: context, private featureDefinitions: Readonly, logger: Logger, - options?: FeaturesOptions & { + options?: { + fallbackFeatures?: Record | string[]; + timeoutMs?: number; + staleTimeMs?: number; + expireTimeMs?: number; cache?: FeatureCache; rateLimiter?: RateLimiter; }, diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 2b4e7bf8..505b9551 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -6,7 +6,6 @@ export type { BucketContext, CompanyContext, UserContext } from "./context"; export type { CheckEvent, FallbackFeatureOverride, - FeaturesOptions, RawFeature, RawFeatures, } from "./feature/features"; diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index 9213a939..b0282fe0 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -5,7 +5,6 @@ import { FeatureDefinitions } from "../src/client"; import { FEATURES_EXPIRE_MS, FeaturesClient, - FeaturesOptions, FetchedFeature, RawFeature, } from "../src/feature/features"; @@ -37,9 +36,9 @@ function featuresClientFactory() { cache, httpClient, newFeaturesClient: function newFeaturesClient( - options?: FeaturesOptions, - context?: any, - featureList: FeatureDefinitions = [], + context?: Record, + features?: FeatureDefinitions, + options?: { staleWhileRevalidate?: boolean; fallbackFeatures?: any }, ) { return new FeaturesClient( httpClient, @@ -49,7 +48,7 @@ function featuresClientFactory() { other: { eventId: "big-conference1" }, ...context, }, - featureList, + features || [], testLogger, { cache, @@ -94,14 +93,11 @@ describe("FeaturesClient", () => { test("ignores undefined context", async () => { const { newFeaturesClient, httpClient } = featuresClientFactory(); - const featuresClient = newFeaturesClient( - {}, - { - user: undefined, - company: undefined, - other: undefined, - }, - ); + const featuresClient = newFeaturesClient({ + user: undefined, + company: undefined, + other: undefined, + }); await featuresClient.initialize(); expect(featuresClient.getFeatures()).toEqual(featuresResult); @@ -126,7 +122,7 @@ describe("FeaturesClient", () => { new Error("Failed to fetch features"), ); - const featuresClient = newFeaturesClient({ + const featuresClient = newFeaturesClient(undefined, undefined, { fallbackFeatures: ["huddle"], }); @@ -147,7 +143,7 @@ describe("FeaturesClient", () => { vi.mocked(httpClient.get).mockRejectedValue( new Error("Failed to fetch features"), ); - const featuresClient = newFeaturesClient({ + const featuresClient = newFeaturesClient(undefined, undefined, { fallbackFeatures: { huddle: { key: "john", @@ -346,7 +342,7 @@ describe("FeaturesClient", () => { const { newFeaturesClient } = featuresClientFactory(); // localStorage.clear(); - const client = newFeaturesClient(undefined, undefined, ["featureB"]); + const client = newFeaturesClient(undefined, ["featureB"]); await client.initialize(); let updated = false; diff --git a/packages/react-sdk/dev/nextjs-flag-demo/components/Providers.tsx b/packages/react-sdk/dev/nextjs-flag-demo/components/Providers.tsx index 03071ae6..d74a62b8 100644 --- a/packages/react-sdk/dev/nextjs-flag-demo/components/Providers.tsx +++ b/packages/react-sdk/dev/nextjs-flag-demo/components/Providers.tsx @@ -14,7 +14,7 @@ export const Providers = ({ publishableKey, children }: Props) => { publishableKey={publishableKey} company={{ id: "acme_inc" }} user={{ id: "john doe" }} - featureOptions={{ fallbackFeatures: ["fallback-feature"] }} + fallbackFeatures={["fallback-feature"]} > {children}
diff --git a/packages/react-sdk/dev/plain/app.tsx b/packages/react-sdk/dev/plain/app.tsx index 6de97fea..3890fa63 100644 --- a/packages/react-sdk/dev/plain/app.tsx +++ b/packages/react-sdk/dev/plain/app.tsx @@ -19,7 +19,7 @@ declare module "../../src" { } const publishableKey = import.meta.env.VITE_PUBLISHABLE_KEY || ""; -const host = import.meta.env.VITE_BUCKET_HOST; +const apiBaseUrl = import.meta.env.VITE_BUCKET_API_BASE_URL; function HuddleFeature() { // Type safe feature @@ -227,7 +227,7 @@ export function App() { company={initialCompany} user={initialUser} otherContext={initialOtherContext} - host={host} + apiBaseUrl={apiBaseUrl} > {} diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 835a094e..9d85387a 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -13,7 +13,6 @@ import canonicalJSON from "canonical-json"; import { BucketClient, BucketContext, - FeaturesOptions, FeedbackOptions, RawFeatures, RequestFeedbackData, @@ -54,30 +53,41 @@ const ProviderContext = createContext({ export type BucketProps = BucketContext & { publishableKey: string; - featureOptions?: Omit & { - fallbackFeatures?: FeatureKey[] | Record; - }; + 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; + features?: Readonly; + + fallbackFeatures?: FeatureKey[] | Record; + + /** + * Timeout in milliseconds when fetching features + */ + timeoutMs?: number; + + /** + * If set to true stale features will be returned while refetching features + */ + staleWhileRevalidate?: boolean; + + /** + * If set, features will be cached between page loads for this duration + */ + expireTimeMs?: number; + + /** + * Stale features will be returned if staleWhileRevalidate is true if no new features can be fetched + */ + staleTimeMs?: number; toolbar?: ToolbarOptions; @@ -93,14 +103,13 @@ export function BucketProvider({ company, otherContext, publishableKey, - featureOptions, loadingComponent, - featureList, + features, newBucketClient = (...args) => new BucketClient(...args), ...config }: BucketProps) { const [featuresLoading, setFeaturesLoading] = useState(true); - const [features, setFeatures] = useState({}); + const [rawFeatures, setRawFeatures] = useState({}); const clientRef = useRef(); const contextKeyRef = useRef(); @@ -127,27 +136,28 @@ export function BucketProvider({ company, otherContext, - host: config.host, apiBaseUrl: config.apiBaseUrl, appBaseUrl: config.appBaseUrl, - sseHost: config.sseHost, sseBaseUrl: config.sseBaseUrl, enableTracking: config.enableTracking, - features: { - ...featureOptions, - }, + staleTimeMs: config.staleTimeMs, + expireTimeMs: config.expireTimeMs, + timeoutMs: config.timeoutMs, + staleWhileRevalidate: config.staleWhileRevalidate, + fallbackFeatures: config.fallbackFeatures, + feedback: config.feedback, logger: config.debug ? console : undefined, sdkVersion: SDK_VERSION, - featureList, + features, toolbar: config.toolbar, }); clientRef.current = client; client.onFeaturesUpdated(() => { - setFeatures(client.getFeatures()); + setRawFeatures(client.getFeatures()); }); client @@ -162,7 +172,7 @@ export function BucketProvider({ const context: ProviderContextType = { features: { - features: features, + features: rawFeatures, isLoading: featuresLoading, }, client: clientRef.current, diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index d53804c3..37eb1495 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -161,12 +161,21 @@ describe("", () => { const provider = getProvider({ publishableKey: "KEY", - apiBaseUrl: "https://test.com", - sseBaseUrl: "https://test.com", + apiBaseUrl: "https://apibaseurl.com", + sseBaseUrl: "https://ssebaseurl.com", company: { id: "123", name: "test" }, user: { id: "456", name: "test" }, otherContext: { test: "test" }, enableTracking: false, + appBaseUrl: "https://appbaseurl.com", + staleTimeMs: 1001, + timeoutMs: 1002, + expireTimeMs: 1003, + staleWhileRevalidate: true, + features: ["feature1"], + fallbackFeatures: ["feature2"], + feedback: { enableAutoFeedback: true }, + toolbar: { show: true }, newBucketClient, }); @@ -186,17 +195,23 @@ describe("", () => { otherContext: { test: "test", }, - apiBaseUrl: "https://test.com", - appBaseUrl: undefined, - host: undefined, + apiBaseUrl: "https://apibaseurl.com", + appBaseUrl: "https://appbaseurl.com", + sseBaseUrl: "https://ssebaseurl.com", logger: undefined, - sseBaseUrl: "https://test.com", - sseHost: undefined, - toolbar: undefined, enableTracking: false, - feedback: undefined, - featureList: undefined, - features: {}, + expireTimeMs: 1003, + fallbackFeatures: ["feature2"], + features: ["feature1"], + feedback: { + enableAutoFeedback: true, + }, + staleTimeMs: 1001, + staleWhileRevalidate: true, + timeoutMs: 1002, + toolbar: { + show: true, + }, sdkVersion: `react-sdk/${version}`, }, ]); From 07acb5653fb22e36bff89e3f99306a2db836d5c2 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 12 Feb 2025 14:04:41 +0100 Subject: [PATCH 37/60] feat(browser-sdk,react-sdk): use remote feature list instead of locally defined list (#315) This removes the need to pass in a feature list to the SDK in order for features to show up in the toolbar. Instead, we'll use the `/features/evaluated` endpoint which returns all features. --- packages/browser-sdk/src/client.ts | 1 - packages/browser-sdk/src/feature/features.ts | 18 ++---------------- .../test/e2e/acceptance.browser.spec.ts | 2 +- .../test/e2e/feedback-widget.browser.spec.ts | 4 ++-- packages/browser-sdk/test/features.test.ts | 13 +++++-------- packages/browser-sdk/test/init.test.ts | 2 +- packages/browser-sdk/test/mocks/handlers.ts | 1 + packages/node-sdk/example/serve.ts | 2 -- packages/react-sdk/test/usage.test.tsx | 2 +- 9 files changed, 13 insertions(+), 32 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 7fde0e29..c47e1335 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -404,7 +404,6 @@ export class BucketClient { company: this.context.company, other: this.context.otherContext, }, - opts?.features || [], this.logger, { expireTimeMs: opts.expireTimeMs, diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index cd536e5d..c825bb21 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -187,7 +187,6 @@ export class FeaturesClient { constructor( private httpClient: HttpClient, private context: context, - private featureDefinitions: Readonly, logger: Logger, options?: { fallbackFeatures?: Record | string[]; @@ -235,9 +234,7 @@ export class FeaturesClient { try { const storedFeatureOverrides = getOverridesCache(); for (const key in storedFeatureOverrides) { - if (this.featureDefinitions.includes(key)) { - this.featureOverrides[key] = storedFeatureOverrides[key]; - } + this.featureOverrides[key] = storedFeatureOverrides[key]; } } catch (e) { this.logger.warn("error getting feature overrides from cache", e); @@ -295,7 +292,7 @@ export class FeaturesClient { const params = this.fetchParams(); try { const res = await this.httpClient.get({ - path: "/features/enabled", + path: "/features/evaluated", timeoutMs: this.config.timeoutMs, params, }); @@ -375,17 +372,6 @@ export class FeaturesClient { }; } - // add any features that aren't in the fetched features - for (const key of this.featureDefinitions) { - if (!mergedFeatures[key]) { - mergedFeatures[key] = { - key, - isEnabled: false, - isEnabledOverride: this.featureOverrides[key] ?? null, - }; - } - } - this.features = mergedFeatures; this.eventTarget.dispatchEvent(new Event(FEATURES_UPDATED_EVENT)); diff --git a/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts b/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts index 1fc5e8c6..3f5f6bb5 100644 --- a/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts +++ b/packages/browser-sdk/test/e2e/acceptance.browser.spec.ts @@ -11,7 +11,7 @@ test("Acceptance", async ({ page }) => { const successfulRequests: string[] = []; // Mock API calls with assertions - await page.route(`${API_BASE_URL}/features/enabled*`, async (route) => { + await page.route(`${API_BASE_URL}/features/evaluated*`, async (route) => { successfulRequests.push("FEATURES"); await route.fulfill({ status: 200, diff --git a/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts b/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts index a982b76f..cfef00c0 100644 --- a/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts +++ b/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts @@ -33,7 +33,7 @@ async function getOpenedWidgetContainer( await route.fulfill({ status: 200 }); }); - await page.route(`${API_HOST}/features/enabled*`, async (route) => { + await page.route(`${API_HOST}/features/evaluated*`, async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ @@ -70,7 +70,7 @@ async function getGiveFeedbackPageContainer( await route.fulfill({ status: 200 }); }); - await page.route(`${API_HOST}/features/enabled*`, async (route) => { + await page.route(`${API_HOST}/features/evaluated*`, async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index b0282fe0..71378e97 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, @@ -37,7 +36,6 @@ function featuresClientFactory() { httpClient, newFeaturesClient: function newFeaturesClient( context?: Record, - features?: FeatureDefinitions, options?: { staleWhileRevalidate?: boolean; fallbackFeatures?: any }, ) { return new FeaturesClient( @@ -48,7 +46,6 @@ function featuresClientFactory() { other: { eventId: "big-conference1" }, ...context, }, - features || [], testLogger, { cache, @@ -87,7 +84,7 @@ describe("FeaturesClient", () => { publishableKey: "pk", }); - expect(path).toEqual("/features/enabled"); + expect(path).toEqual("/features/evaluated"); expect(timeoutMs).toEqual(5000); }); @@ -111,7 +108,7 @@ describe("FeaturesClient", () => { publishableKey: "pk", }); - expect(path).toEqual("/features/enabled"); + expect(path).toEqual("/features/evaluated"); expect(timeoutMs).toEqual(5000); }); @@ -122,7 +119,7 @@ describe("FeaturesClient", () => { new Error("Failed to fetch features"), ); - const featuresClient = newFeaturesClient(undefined, undefined, { + const featuresClient = newFeaturesClient(undefined, { fallbackFeatures: ["huddle"], }); @@ -143,7 +140,7 @@ describe("FeaturesClient", () => { vi.mocked(httpClient.get).mockRejectedValue( new Error("Failed to fetch features"), ); - const featuresClient = newFeaturesClient(undefined, undefined, { + const featuresClient = newFeaturesClient(undefined, { fallbackFeatures: { huddle: { key: "john", @@ -342,7 +339,7 @@ describe("FeaturesClient", () => { const { newFeaturesClient } = featuresClientFactory(); // localStorage.clear(); - const client = newFeaturesClient(undefined, ["featureB"]); + const client = newFeaturesClient(undefined); await client.initialize(); let updated = false; diff --git a/packages/browser-sdk/test/init.test.ts b/packages/browser-sdk/test/init.test.ts index b37a941d..5c5b4363 100644 --- a/packages/browser-sdk/test/init.test.ts +++ b/packages/browser-sdk/test/init.test.ts @@ -40,7 +40,7 @@ describe("init", () => { server.use( http.get( - "https://example.com/features/enabled", + "https://example.com/features/evaluated", ({ request }: { request: StrictRequest }) => { usedSpecialHost = true; return getFeatures({ request }); diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 62fd5fe1..58902be2 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -151,6 +151,7 @@ export const handlers = [ }); }), http.get("https://front.bucket.co/features/enabled", getFeatures), + http.get("https://front.bucket.co/features/evaluated", getFeatures), http.post( "https://front.bucket.co/feedback/prompting-init", ({ request }) => { diff --git a/packages/node-sdk/example/serve.ts b/packages/node-sdk/example/serve.ts index ada781f5..37373bfa 100644 --- a/packages/node-sdk/example/serve.ts +++ b/packages/node-sdk/example/serve.ts @@ -6,8 +6,6 @@ import app from "./app"; // Initialize Bucket SDK before starting the server, // so that features are available when the server starts. bucket.initialize().then(() => { - console.log("Bucket initialized"); - // Start listening for requests only after Bucket is initialized, // which guarantees that features are available. app.listen(process.env.PORT ?? 3000, () => { diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 37eb1495..8254d245 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -72,7 +72,7 @@ const server = setupServer( { status: 200 }, ); }), - http.get(/\/features\/enabled$/, () => { + http.get(/\/features\/evaluated$/, () => { return new HttpResponse( JSON.stringify({ success: true, From 8e60fee19dbb2f6d31e1b2c7120f9976831dfb8a Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Tue, 28 Jan 2025 13:45:27 +0100 Subject: [PATCH 38/60] feat(browser-sdk,node-sdk): add avatar support for user and company contexts (#297) - Updated README files to document new `avatar` attribute - Added `avatar` to user and company context types in browser and node SDKs - Expanded documentation for special attributes in context objects - Added type definitions for avatar in relevant type files --- packages/browser-sdk/src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index c47e1335..7077345a 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -312,7 +312,7 @@ export type FeatureRemoteConfig = | { key: undefined; payload: undefined }; /** - * A feature. + * Represents a feature. */ export interface Feature { /** From 0b6855a03f5cfa861e19624cd382f63a1c5ee6e5 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 12 Feb 2025 14:35:04 +0100 Subject: [PATCH 39/60] feat(browser-sdk,react-sdk): check events (#316) --- packages/browser-sdk/FEEDBACK.md | 12 +- packages/browser-sdk/README.md | 99 +++++-- packages/browser-sdk/package.json | 2 +- packages/browser-sdk/src/client.ts | 42 ++- .../browser-sdk/src/feature/featureCache.ts | 8 +- packages/browser-sdk/src/feature/features.ts | 82 +++++- packages/browser-sdk/test/features.test.ts | 27 ++ packages/browser-sdk/test/mocks/handlers.ts | 4 + packages/browser-sdk/test/testLogger.ts | 22 +- packages/browser-sdk/test/usage.test.ts | 18 +- packages/react-sdk/README.md | 259 +++++++++++++----- packages/react-sdk/package.json | 4 +- packages/react-sdk/src/index.tsx | 29 +- yarn.lock | 4 +- 14 files changed, 433 insertions(+), 179 deletions(-) diff --git a/packages/browser-sdk/FEEDBACK.md b/packages/browser-sdk/FEEDBACK.md index b333591a..f93d2626 100644 --- a/packages/browser-sdk/FEEDBACK.md +++ b/packages/browser-sdk/FEEDBACK.md @@ -13,7 +13,7 @@ the viewport, displayed in English, and with a [light-mode theme](#custom-stylin These settings can be overwritten when initializing the Bucket Browser SDK: -```javascript +```typescript const bucket = new BucketClient({ publishableKey: "bucket-publishable-key", user: { id: "42" }, @@ -39,11 +39,9 @@ const bucket = new BucketClient({ See also: -- [Positioning and behavior](#positioning-and-behavior) for the position option. -- [Static language configuration](#static-language-configuration) if you want to - translate the feedback UI. -- [Automated feedback surveys](#automated-feedback-surveys) to - override default configuration. +- [Positioning and behavior](#positioning-and-behavior) for the position option, +- [Static language configuration](#static-language-configuration) if you want to translate the feedback UI, +- [Automated feedback surveys](#automated-feedback-surveys) to override default configuration. ## Automated feedback surveys @@ -63,7 +61,7 @@ The live connection for automated feedback is established when the You can disable automated collection in the `BucketClient` constructor: -```javascript +```typescript const bucket = new BucketClient({ publishableKey: "bucket-publishable-key", user: { id: "42" }, diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index eec910a2..67597945 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -8,9 +8,9 @@ First find your `publishableKey` under [environment settings](https://app.bucket The package can be imported or used directly in a HTML script tag: -A. Import module +A. Import module: -```ts +```typescript import { BucketClient } from "@bucketco/browser-sdk"; const user = { @@ -93,19 +93,21 @@ See [example/browser.html](https://github.com/bucketco/bucket-javascript-sdk/tre Supply these to the constructor call: -```ts +```typescript type Configuration = { logger: console; // by default only logs warn/error, by passing `console` you'll log everything apiBaseUrl?: "https://front.bucket.co"; sseBaseUrl?: "https://livemessaging.bucket.co"; feedback?: undefined; // See FEEDBACK.md - enableTracking?: true; // set to `false` to stop sending track events and user/company updates to Bucket servers. Useful when you're impersonating a user. + enableTracking?: true; // set to `false` to stop sending track events and user/company updates to Bucket servers. Useful when you're impersonating a user featureOptions?: { - fallbackFeatures?: string[]; // Enable these features if unable to contact bucket.co - timeoutMs?: number; // Timeout for fetching features - staleWhileRevalidate?: boolean; // Revalidate in the background when cached features turn stale to avoid latency in the UI - staleTimeMs?: number; // at initialization time features are loaded from the cache unless they have gone stale. Defaults to 0 which means the cache is disabled. Increase in the case of a non-SPA. - expireTimeMs?: number; // In case we're unable to fetch features from Bucket, cached/stale features will be used instead until they expire after `expireTimeMs`. + fallbackFeatures?: + | string[] + | Record; // Enable these features if unable to contact bucket.co. Can be a list of feature keys or a record with configuration values + timeoutMs?: number; // Timeout for fetching features (default: 5000ms) + staleWhileRevalidate?: boolean; // Revalidate in the background when cached features turn stale to avoid latency in the UI (default: false) + staleTimeMs?: number; // at initialization time features are loaded from the cache unless they have gone stale. Defaults to 0 which means the cache is disabled. Increase in the case of a non-SPA + expireTimeMs?: number; // In case we're unable to fetch features from Bucket, cached/stale features will be used instead until they expire after `expireTimeMs`. Default is 30 days }; }; ``` @@ -120,9 +122,9 @@ In addition to the `id`, you must also supply anything additional that you want Attributes cannot be nested (multiple levels) and must be either strings, integers or booleans. Some attributes are special and used in Bucket UI: -- `name` is used to display name for `user`/`company`, -- `email` is accepted for `user`s and will be highlighted in the Bucket UI if available, -- `avatar` can be provided for both `user` and `company` and should be an URL to an image. +- `name` -- display name for `user`/`company`, +- `email` -- is accepted for `user`s and will be highlighted in the Bucket UI if available, +- `avatar` -- can be provided for both `user` and `company` and should be an URL to an image. ```ts const bucketClient = new BucketClient({ @@ -172,6 +174,43 @@ 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 returned by `getFeature`. +### Feature Overrides + +You can override feature flags locally for testing purposes using `setFeatureOverride`: + +```ts +// Override a feature to be enabled +bucketClient.setFeatureOverride("huddle", true); + +// Override a feature to be disabled +bucketClient.setFeatureOverride("huddle", false); + +// Remove the override +bucketClient.setFeatureOverride("huddle", null); + +// Get current override value +const override = bucketClient.getFeatureOverride("huddle"); // returns boolean | null +``` + +Feature overrides are persisted in `localStorage` and will be restored when the page is reloaded. + +### Feature Updates + +You can listen for feature updates using `onFeaturesUpdated`: + +```ts +// Register a callback for feature updates +const unsubscribe = bucketClient.onFeaturesUpdated(() => { + console.log("Features were updated"); +}); + +// Later, stop listening for updates +unsubscribe(); +``` + +> [!NOTE] +> Note that the callback may be called even if features haven't actually changed. + ### Remote config Similar to `isEnabled`, each feature has a `config` property. This configuration is managed from within Bucket. @@ -225,7 +264,7 @@ const { isEnabled } = bucketClient.getFeature("voiceHuddle"); await bucketClient.updateUser({ voiceHuddleOptIn: (!isEnabled).toString() }); ``` -Note that user/company attributes are also stored remotely on the Bucket servers and will automatically be used to evaluate feature targeting if the page is refreshed. +> [!NOTE] > `user`/`company` attributes are also stored remotely on the Bucket servers and will automatically be used to evaluate feature targeting if the page is refreshed. ### Qualitative feedback @@ -235,7 +274,8 @@ Bucket can collect qualitative feedback from your users in the form of a [Custom The Bucket Browser SDK comes with automated feedback collection mode enabled by default, which lets the Bucket service ask your users for feedback for relevant features just after they've used them. -Note: To get started with automatic feedback collection, make sure you've set `user` in the `BucketClient` constructor. +> [!NOTE] +> To get started with automatic feedback collection, make sure you've set `user` in the `BucketClient` constructor. Automated feedback surveys work even if you're not using the SDK to send events to Bucket. It works because the Bucket Browser SDK maintains a live connection to Bucket's servers and can automatically show a feedback prompt whenever the Bucket servers determines that an event should trigger a prompt - regardless of how this event is sent to Bucket. @@ -262,7 +302,7 @@ bucketClient.feedback({ }); ``` -#### Bucket feedback API +### Bucket feedback API If you are not using the Bucket Browser SDK, you can still submit feedback using the HTTP API. @@ -274,9 +314,9 @@ The Bucket Browser SDK doesn't collect any metadata and HTTP IP addresses are _n For tracking individual users, we recommend using something like database ID as userId, as it's unique and doesn't include any PII (personal identifiable information). If, however, you're using e.g. email address as userId, but prefer not to send any PII to Bucket, you can hash the sensitive data before sending it to Bucket: -``` +```ts import bucket from "@bucketco/browser-sdk"; -import { sha256 } from 'crypto-hash'; +import { sha256 } from "crypto-hash"; bucket.user(await sha256("john_doe")); ``` @@ -290,7 +330,7 @@ The two cookies are: - `bucket-prompt-${userId}`: store the last automated feedback prompt message ID received to avoid repeating surveys - `bucket-token-${userId}`: caching a token used to connect to Bucket's live messaging infrastructure that is used to deliver automated feedback surveys in real time. -### Typescript +### TypeScript Types are bundled together with the library and exposed automatically when importing through a package manager. @@ -298,20 +338,19 @@ Types are bundled together with the library and exposed automatically when impor If you are running with strict Content Security Policies active on your website, you will need to enable these directives in order to use the SDK: -| Directive | Values | Reason | -| ----------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| connect-src | https://front.bucket.co | Basic functionality` | -| connect-src | https://livemessaging.bucket.co | Server sent events for use in automated feedback surveys, which allows for automatically collecting feedback when a user used a feature. | -| style-src | 'unsafe-inline' | The feedback UI is styled with inline styles. Not having this directive results unstyled HTML elements. | +| Directive | Values | Reason | +| ----------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| connect-src | [https://front.bucket.co](https://front.bucket.co) | Basic functionality` | +| connect-src | [https://livemessaging.bucket.co](https://livemessaging.bucket.co) | Server sent events for use in automated feedback surveys, which allows for automatically collecting feedback when a user used a feature. | +| style-src | 'unsafe-inline' | The feedback UI is styled with inline styles. Not having this directive results unstyled HTML elements. | If you are including the Bucket tracking SDK with a ` diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index b59785e8..4930702e 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -16,6 +16,7 @@ import * as feedbackLib from "./feedback/ui"; import { ToolbarPosition } from "./toolbar/Toolbar"; import { API_BASE_URL, APP_BASE_URL, SSE_REALTIME_BASE_URL } from "./config"; import { BucketContext, CompanyContext, UserContext } from "./context"; +import { HookArgs, HooksManager } from "./hooksManager"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; import { showToolbarToggle } from "./toolbar"; @@ -356,6 +357,8 @@ export class BucketClient { public readonly logger: Logger; + private readonly hooks: HooksManager; + /** * Create a new BucketClient instance. */ @@ -433,6 +436,12 @@ export class BucketClient { typeof opts.toolbar === "object" ? opts.toolbar.position : undefined, }); } + + // Register hooks + this.hooks = new HooksManager(); + this.featuresClient.onUpdated(() => { + this.hooks.trigger("featuresUpdated", this.featuresClient.getFeatures()); + }); } /** @@ -463,6 +472,31 @@ export class BucketClient { } } + /** + * Add a hook to the client. + * + * @param hook Hook to add. + */ + on( + type: THookType, + handler: (args0: HookArgs[THookType]) => void, + ) { + this.hooks.addHook(type, handler); + } + + /** + * Remove a hook from the client. + * + * @param hook Hook to add. + * @returns A function to remove the hook. + */ + off( + type: THookType, + handler: (args0: HookArgs[THookType]) => void, + ) { + return this.hooks.removeHook(type, handler); + } + /** * Get the current configuration. */ @@ -534,18 +568,6 @@ export class BucketClient { await this.featuresClient.setContext(this.context); } - /** - * Register a callback to be called when the features are updated. - * Features are not guaranteed to have actually changed when the callback is called. - * - * Calling `client.stop()` will remove all listeners added here. - * - * @param cb The callback to call when the update completes. - */ - onFeaturesUpdated(cb: () => void) { - return this.featuresClient.onUpdated(cb); - } - /** * Track an event in Bucket. * @@ -572,6 +594,13 @@ export class BucketClient { const res = await this.httpClient.post({ path: `/event`, body: payload }); this.logger.debug(`sent event`, res); + + this.hooks.trigger("track", { + eventName, + attributes, + user: this.context.user, + company: this.context.company, + }); return res; } @@ -684,7 +713,8 @@ export class BucketClient { getFeature(key: string): Feature { const f = this.getFeatures()[key]; - const fClient = this.featuresClient; + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; const value = f?.isEnabledOverride ?? f?.isEnabled ?? false; const config = f?.config ? { @@ -695,9 +725,9 @@ export class BucketClient { return { get isEnabled() { - fClient + self .sendCheckEvent({ - action: "check", + action: "check-is-enabled", key, version: f?.targetingVersion, ruleEvaluationResults: f?.ruleEvaluationResults, @@ -710,7 +740,7 @@ export class BucketClient { return value; }, get config() { - fClient + self .sendCheckEvent({ action: "check-config", key, @@ -749,7 +779,12 @@ export class BucketClient { } sendCheckEvent(checkEvent: CheckEvent) { - return this.featuresClient.sendCheckEvent(checkEvent); + return this.featuresClient.sendCheckEvent(checkEvent, () => { + this.hooks.trigger( + checkEvent.action == "check-config" ? "configCheck" : "enabledCheck", + checkEvent, + ); + }); } /** @@ -787,6 +822,8 @@ export class BucketClient { }; const res = await this.httpClient.post({ path: `/user`, body: payload }); this.logger.debug(`sent user`, res); + + this.hooks.trigger("user", this.context.user); return res; } @@ -817,6 +854,7 @@ export class BucketClient { const res = await this.httpClient.post({ path: `/company`, body: payload }); this.logger.debug(`sent company`, res); + this.hooks.trigger("company", this.context.company); return res; } } diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index da3decd7..8ed47fe9 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -69,7 +69,7 @@ export type FetchedFeature = { }; }; -const FEATURES_UPDATED_EVENT = "features-updated"; +const FEATURES_UPDATED_EVENT = "featuresUpdated"; export type FetchedFeatures = Record; @@ -145,7 +145,7 @@ export interface CheckEvent { /** * Action to perform. */ - action: "check" | "check-config"; + action: "check-is-enabled" | "check-config"; /** * Feature key. @@ -299,20 +299,12 @@ export class FeaturesClient { * Features are not guaranteed to have actually changed when the callback is called. * * @param callback this will be called when the features are updated. - * @param options passed as-is to addEventListener, except the abort signal is not supported. * @returns a function that can be called to remove the listener */ - onUpdated(callback: () => void, options?: AddEventListenerOptions | boolean) { + onUpdated(callback: () => void) { this.eventTarget.addEventListener(FEATURES_UPDATED_EVENT, callback, { signal: this.abortController.signal, }); - return () => { - this.eventTarget.removeEventListener( - FEATURES_UPDATED_EVENT, - callback, - options, - ); - }; } getFeatures(): RawFeatures { @@ -365,10 +357,10 @@ export class FeaturesClient { * * * @param checkEvent - The feature to send the event for. + * @param cb - Callback to call after the event is sent. Might be skipped if the event was rate limited. */ - async sendCheckEvent(checkEvent: CheckEvent) { + async sendCheckEvent(checkEvent: CheckEvent, cb: () => void) { const rateLimitKey = `check-event:${this.fetchParams().toString()}:${checkEvent.key}:${checkEvent.version}:${checkEvent.value}`; - await this.rateLimiter.rateLimited(rateLimitKey, async () => { const payload = { action: checkEvent.action, @@ -390,6 +382,7 @@ export class FeaturesClient { }); this.logger.debug(`sent feature event`, payload); + cb(); }); return checkEvent.value; diff --git a/packages/browser-sdk/src/hooksManager.ts b/packages/browser-sdk/src/hooksManager.ts new file mode 100644 index 00000000..9c0ea402 --- /dev/null +++ b/packages/browser-sdk/src/hooksManager.ts @@ -0,0 +1,67 @@ +import { CheckEvent, RawFeatures } from "./feature/features"; +import { CompanyContext, UserContext } from "./context"; + +/** + * @internal + */ +export interface HookArgs { + configCheck: CheckEvent; + enabledCheck: CheckEvent; + featuresUpdated: RawFeatures; + user: UserContext; + company: CompanyContext; + track: TrackEvent; +} + +type TrackEvent = { + user: UserContext; + company?: CompanyContext; + eventName: string; + attributes?: Record | null; +}; + +/** + * Hooks manager. + * @internal + */ +export class HooksManager { + private hooks: { + enabledCheck: ((arg0: CheckEvent) => void)[]; + configCheck: ((arg0: CheckEvent) => void)[]; + featuresUpdated: ((arg0: RawFeatures) => void)[]; + user: ((arg0: UserContext) => void)[]; + company: ((arg0: CompanyContext) => void)[]; + track: ((arg0: TrackEvent) => void)[]; + } = { + enabledCheck: [], + configCheck: [], + featuresUpdated: [], + user: [], + company: [], + track: [], + }; + + addHook( + event: THookType, + cb: (arg0: HookArgs[THookType]) => void, + ): () => void { + (this.hooks[event] as any[]).push(cb); + return () => { + this.removeHook(event, cb); + }; + } + + removeHook( + event: THookType, + cb: (arg0: HookArgs[THookType]) => void, + ): void { + this.hooks[event] = this.hooks[event].filter((hook) => hook !== cb) as any; + } + + trigger( + event: THookType, + arg: HookArgs[THookType], + ): void { + this.hooks[event].forEach((hook) => hook(arg as any)); + } +} diff --git a/packages/browser-sdk/src/toolbar/Toolbar.tsx b/packages/browser-sdk/src/toolbar/Toolbar.tsx index b30efa0f..1e752cce 100644 --- a/packages/browser-sdk/src/toolbar/Toolbar.tsx +++ b/packages/browser-sdk/src/toolbar/Toolbar.tsx @@ -59,7 +59,7 @@ export default function Toolbar({ useEffect(() => { updateFeatures(); - return bucketClient.onFeaturesUpdated(updateFeatures); + bucketClient.on("featuresUpdated", updateFeatures); }, [bucketClient]); const [search, setSearch] = useState(null); diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 76cd1c24..3034ce89 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -74,4 +74,78 @@ describe("BucketClient", () => { expect(client.getFeature("featureA").isEnabled).toBe(false); }); }); + + describe("hooks integration", () => { + it("on adds hooks appropriately, off removes them", async () => { + const trackHook = vi.fn(); + const userHook = vi.fn(); + const companyHook = vi.fn(); + const checkHookIsEnabled = vi.fn(); + const checkHookConfig = vi.fn(); + const featuresUpdated = vi.fn(); + + client.on("track", trackHook); + client.on("user", userHook); + client.on("company", companyHook); + client.on("configCheck", checkHookConfig); + client.on("enabledCheck", checkHookIsEnabled); + client.on("featuresUpdated", featuresUpdated); + + await client.track("test-event"); + expect(trackHook).toHaveBeenCalledWith({ + eventName: "test-event", + attributes: undefined, + user: client["context"].user, + company: client["context"].company, + }); + + await client["user"](); + expect(userHook).toHaveBeenCalledWith(client["context"].user); + + await client["company"](); + expect(companyHook).toHaveBeenCalledWith(client["context"].company); + + client.getFeature("featureA").isEnabled; + expect(checkHookIsEnabled).toHaveBeenCalled(); + + client.getFeature("featureA").config; + expect(checkHookConfig).toHaveBeenCalled(); + + expect(featuresUpdated).not.toHaveBeenCalled(); + await client.updateOtherContext({ key: "value" }); + expect(featuresUpdated).toHaveBeenCalled(); + + // Remove hooks + client.off("track", trackHook); + client.off("user", userHook); + client.off("company", companyHook); + client.off("configCheck", checkHookConfig); + client.off("enabledCheck", checkHookIsEnabled); + client.off("featuresUpdated", featuresUpdated); + + // Reset mocks + trackHook.mockReset(); + userHook.mockReset(); + companyHook.mockReset(); + checkHookIsEnabled.mockReset(); + checkHookConfig.mockReset(); + featuresUpdated.mockReset(); + + // Trigger events again + await client.track("test-event"); + await client["user"](); + await client["company"](); + client.getFeature("featureA").isEnabled; + client.getFeature("featureA").config; + await client.updateOtherContext({ key: "value" }); + + // Ensure hooks are not called + expect(trackHook).not.toHaveBeenCalled(); + expect(userHook).not.toHaveBeenCalled(); + expect(companyHook).not.toHaveBeenCalled(); + expect(checkHookIsEnabled).not.toHaveBeenCalled(); + expect(checkHookConfig).not.toHaveBeenCalled(); + expect(featuresUpdated).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/browser-sdk/test/hooksManager.test.ts b/packages/browser-sdk/test/hooksManager.test.ts new file mode 100644 index 00000000..ae17de64 --- /dev/null +++ b/packages/browser-sdk/test/hooksManager.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { CompanyContext, UserContext } from "../src"; +import { CheckEvent, RawFeatures } from "../src/feature/features"; +import { HooksManager } from "../src/hooksManager"; + +describe("HookManager", () => { + let hookManager: HooksManager; + + beforeEach(() => { + hookManager = new HooksManager(); + }); + + it("should add and trigger check-is-enabled hooks", () => { + const callback = vi.fn(); + hookManager.addHook("enabledCheck", callback); + + const checkEvent: CheckEvent = { + action: "check-is-enabled", + key: "test-key", + value: true, + }; + hookManager.trigger("enabledCheck", checkEvent); + + expect(callback).toHaveBeenCalledWith(checkEvent); + }); + + it("should add and trigger configCheck hooks", () => { + const callback = vi.fn(); + hookManager.addHook("configCheck", callback); + + const checkEvent: CheckEvent = { + action: "check-config", + key: "test-key", + value: { key: "key", payload: "payload" }, + }; + hookManager.trigger("configCheck", checkEvent); + + expect(callback).toHaveBeenCalledWith(checkEvent); + }); + + it("should add and trigger features-updated hooks", () => { + const callback = vi.fn(); + hookManager.addHook("featuresUpdated", callback); + + const features: RawFeatures = { + /* mock RawFeatures data */ + }; + hookManager.trigger("featuresUpdated", features); + + expect(callback).toHaveBeenCalledWith(features); + }); + + it("should add and trigger track hooks", () => { + const callback = vi.fn(); + const user: UserContext = { id: "user-id", name: "user-name" }; + const company: CompanyContext = { id: "company-id", name: "company-name" }; + hookManager.addHook("track", callback); + + const eventName = "test-event"; + const attributes = { key: "value" }; + hookManager.trigger("track", { eventName, attributes, user, company }); + + expect(callback).toHaveBeenCalledWith({ + eventName, + attributes, + user, + company, + }); + }); + + it("should add and trigger user hooks", () => { + const callback = vi.fn(); + + hookManager.addHook("user", callback); + + const user = { id: "user-id", name: "user-name" }; + hookManager.trigger("user", user); + + expect(callback).toHaveBeenCalledWith(user); + }); + + it("should add and trigger company hooks", () => { + const callback = vi.fn(); + hookManager.addHook("company", callback); + + const company = { id: "company-id", name: "company-name" }; + hookManager.trigger("company", company); + + expect(callback).toHaveBeenCalledWith(company); + }); + + it("should handle multiple hooks of the same type", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + hookManager.addHook("enabledCheck", callback1); + hookManager.addHook("enabledCheck", callback2); + + const checkEvent: CheckEvent = { + action: "check-is-enabled", + key: "test-key", + value: true, + }; + hookManager.trigger("enabledCheck", checkEvent); + + expect(callback1).toHaveBeenCalledWith(checkEvent); + expect(callback2).toHaveBeenCalledWith(checkEvent); + }); + + it("should remove the given hook and no other hooks", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + hookManager.addHook("enabledCheck", callback1); + hookManager.addHook("enabledCheck", callback2); + hookManager.removeHook("enabledCheck", callback1); + + const checkEvent: CheckEvent = { + action: "check-is-enabled", + key: "test-key", + value: true, + }; + hookManager.trigger("enabledCheck", checkEvent); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledWith(checkEvent); + }); + + it("should remove the hook using the function returned from addHook", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const removeHook1 = hookManager.addHook("enabledCheck", callback1); + hookManager.addHook("enabledCheck", callback2); + removeHook1(); + + const checkEvent: CheckEvent = { + action: "check-is-enabled", + key: "test-key", + value: true, + }; + hookManager.trigger("enabledCheck", checkEvent); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledWith(checkEvent); + }); +}); diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index b1b7d9f5..5325eaf7 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -457,18 +457,21 @@ describe(`sends "check" events `, () => { expect(featureA.isEnabled).toBe(true); expect(sendCheckEventSpy).toHaveBeenCalledTimes(1); - expect(sendCheckEventSpy).toHaveBeenCalledWith({ - action: "check", - key: "featureA", - value: true, - version: 1, - missingContextFields: ["field1", "field2"], - ruleEvaluationResults: [false, true], - }); + expect(sendCheckEventSpy).toHaveBeenCalledWith( + { + action: "check-is-enabled", + key: "featureA", + value: true, + version: 1, + missingContextFields: ["field1", "field2"], + ruleEvaluationResults: [false, true], + }, + expect.any(Function), + ); expect(postSpy).toHaveBeenCalledWith({ body: { - action: "check", + action: "check-is-enabled", evalContext: { company: { id: "cid", @@ -543,12 +546,15 @@ describe(`sends "check" events `, () => { ).toHaveBeenCalledTimes(1); expect( vi.mocked(FeaturesClient.prototype.sendCheckEvent), - ).toHaveBeenCalledWith({ - action: "check", - value: false, - key: "non-existent", - version: undefined, - }); + ).toHaveBeenCalledWith( + { + action: "check-is-enabled", + value: false, + key: "non-existent", + version: undefined, + }, + expect.any(Function), + ); }); it("calls client.track with the featureId", async () => { diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 316b1281..f90b725a 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -124,9 +124,7 @@ export function BucketProvider({ clientRef.current = client; - client.onFeaturesUpdated(() => { - setRawFeatures(client.getFeatures()); - }); + client.on("featuresUpdated", setRawFeatures); client .initialize() diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 11256dd6..4194bbd0 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -152,11 +152,11 @@ beforeEach(() => { describe("", () => { test("calls initialize", () => { - const onFeaturesUpdated = vi.fn(); + const on = vi.fn(); const newBucketClient = vi.fn().mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), - onFeaturesUpdated, + on, }); const provider = getProvider({ @@ -172,7 +172,6 @@ describe("", () => { timeoutMs: 1002, expireTimeMs: 1003, staleWhileRevalidate: true, - features: ["feature1"], fallbackFeatures: ["feature2"], feedback: { enableAutoFeedback: true }, toolbar: { show: true }, @@ -202,7 +201,6 @@ describe("", () => { enableTracking: false, expireTimeMs: 1003, fallbackFeatures: ["feature2"], - features: ["feature1"], feedback: { enableAutoFeedback: true, }, @@ -216,7 +214,7 @@ describe("", () => { }, ]); - expect(onFeaturesUpdated).toBeTruthy(); + expect(on).toBeTruthy(); }); test("only calls init once with the same args", () => { From 41cb74cc3af76351ba55afe6fba9876704c6b8eb Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Thu, 20 Feb 2025 16:40:50 +0700 Subject: [PATCH 49/60] feat(openfeature-browser-provider): add fallback features and improve huddle feature example (#331) Update example to demonstrate fallback features with the Bucket OpenFeature provider, including: - Add fallback configuration for the huddle feature - Enhance HuddleFeature component to display provider and join URL - Update browser SDK dependency to 3.0.0-alpha.4 --- .../example/app/featureManagement.ts | 12 ++++++++++- .../example/components/HuddleFeature.tsx | 20 ++++++++++++++++++- .../openfeature-browser-provider/package.json | 2 +- yarn.lock | 14 +------------ 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/openfeature-browser-provider/example/app/featureManagement.ts b/packages/openfeature-browser-provider/example/app/featureManagement.ts index 2cb569a2..41f67acd 100644 --- a/packages/openfeature-browser-provider/example/app/featureManagement.ts +++ b/packages/openfeature-browser-provider/example/app/featureManagement.ts @@ -18,7 +18,17 @@ export async function initOpenFeature() { console.error("No publishable key set for Bucket"); return; } - bucketProvider = new BucketBrowserSDKProvider({ publishableKey }); + bucketProvider = new BucketBrowserSDKProvider({ + publishableKey, + fallbackFeatures: { + huddle: { + key: "zoom", + payload: { + joinUrl: "https://zoom.us/join", + }, + }, + }, + }); return OpenFeature.setProviderAndWait(bucketProvider); } diff --git a/packages/openfeature-browser-provider/example/components/HuddleFeature.tsx b/packages/openfeature-browser-provider/example/components/HuddleFeature.tsx index bfcedae2..55d03c91 100644 --- a/packages/openfeature-browser-provider/example/components/HuddleFeature.tsx +++ b/packages/openfeature-browser-provider/example/components/HuddleFeature.tsx @@ -1,19 +1,37 @@ "use client"; import React from "react"; -import { useBooleanFlagValue } from "@openfeature/react-sdk"; +import { + useBooleanFlagValue, + useObjectFlagDetails, +} from "@openfeature/react-sdk"; import { track } from "@/app/featureManagement"; const featureKey = "huddle"; export const HuddleFeature = () => { const isEnabled = useBooleanFlagValue(featureKey, false); + const { variant: provider, value: config } = useObjectFlagDetails( + featureKey, + { + joinUrl: "https://zoom.us/join", + }, + ); + return (

Huddle feature enabled:

         {JSON.stringify(isEnabled)}
       
+

+ Huddle using {provider}: +

+
+        
+          Join the huddle at {config.joinUrl}
+        
+