@techdentalkart/features 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/dist/context.d.ts +79 -0
- package/dist/context.js +64 -0
- package/dist/exposures.d.ts +59 -0
- package/dist/exposures.js +0 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +152 -0
- package/dist/institute-management/index.d.ts +28 -0
- package/dist/institute-management/index.js +48 -0
- package/dist/nest/index.d.ts +44 -0
- package/dist/nest/index.js +124 -0
- package/dist/next/index.d.ts +45 -0
- package/dist/next/index.js +102 -0
- package/dist/providers/file.d.ts +18 -0
- package/dist/providers/file.js +76 -0
- package/dist/providers/growthbook.d.ts +46 -0
- package/dist/providers/growthbook.js +143 -0
- package/dist/providers/off.d.ts +10 -0
- package/dist/providers/off.js +15 -0
- package/dist/providers/provider.d.ts +10 -0
- package/dist/providers/provider.js +2 -0
- package/dist/react/index.d.ts +50 -0
- package/dist/react/index.js +104 -0
- package/dist/website/index.d.ts +55 -0
- package/dist/website/index.js +84 -0
- package/package.json +98 -0
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# @techdentalkart/features
|
|
2
|
+
|
|
3
|
+
Feature flags and experiments for every Dentalkart repo. One package, one object, one hook. The rules that decide
|
|
4
|
+
who sees what live in the admin panel (**Feature Management & A/B Testing**); this package only asks and answers.
|
|
5
|
+
End users never see flag names.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
type Flag = { on: boolean; value?: string | number | boolean; experimentId?: string; variationId?: number }
|
|
9
|
+
& Record<string, string | number | boolean | undefined>; // JSON flags spread their attributes
|
|
10
|
+
type FlagSet = Record<string, Flag>; // keyed by "area.feature"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Flag type in the admin | What you get |
|
|
14
|
+
|---|---|
|
|
15
|
+
| On / off | `{ on }` |
|
|
16
|
+
| Number (a percent, a limit) | `{ on: value !== 0, value }` |
|
|
17
|
+
| Text (a message) | `{ on: value !== "", value }` |
|
|
18
|
+
| JSON | `{ on: enabled ?? true, ...attributes }` |
|
|
19
|
+
| decided by an experiment | the same, plus `experimentId` and `variationId` |
|
|
20
|
+
|
|
21
|
+
Unknown flag, engine down, `FEATURES_SOURCE` unset: `{ on: false }`. Never `undefined`, never throws.
|
|
22
|
+
Flags are **discovered**, not declared: nothing in a repo lists flag names. The only place a name appears is the
|
|
23
|
+
line that uses it.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm i @techdentalkart/features
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
That is the whole setup. The package is published to npmjs.com, so there is no registry file, no
|
|
32
|
+
token and nothing to configure — on a laptop or in CI. It is also mirrored to the
|
|
33
|
+
feature-management project's own GitLab registry, which is what `publishConfig` points at.
|
|
34
|
+
|
|
35
|
+
## Next.js (website, Institute Management): one file, one line, one route
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// lib/features.ts — the only flag file in the repo
|
|
39
|
+
import { createNextFeatures } from "@techdentalkart/features/next";
|
|
40
|
+
import { websiteContext } from "@techdentalkart/features/website"; // or instituteContext from ./institute-management
|
|
41
|
+
|
|
42
|
+
export const { getFlags, FeaturesProvider, exposuresRoute } = createNextFeatures({
|
|
43
|
+
context: async ({ cookie, header }) => websiteContext({
|
|
44
|
+
anonymousId: header("x-dk-vid") ?? cookie("dk_vid"), // minted in middleware.ts, the unit of every experiment
|
|
45
|
+
pincode: cookie("pincode"),
|
|
46
|
+
userAgent: header("user-agent"),
|
|
47
|
+
customer: await fetchCustomer(cookie("token")), // this repo's own HTTP call, or omit when signed out
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
```tsx
|
|
52
|
+
// app/layout.tsx
|
|
53
|
+
import { FeaturesProvider } from "@/lib/features";
|
|
54
|
+
<FeaturesProvider>{children}</FeaturesProvider>
|
|
55
|
+
|
|
56
|
+
// app/api/exposures/route.ts — browsers report "I saw variant B" here; it is forwarded to the engine
|
|
57
|
+
export { exposuresRoute as POST } from "@/lib/features";
|
|
58
|
+
```
|
|
59
|
+
```tsx
|
|
60
|
+
// where a price is shown
|
|
61
|
+
import { usePricingVariant } from "@techdentalkart/features/website";
|
|
62
|
+
const pricing = usePricingVariant(); // { percent, experimentId?, variationId?, apply(price) }
|
|
63
|
+
const shown = pricing.apply(sellingPrice);
|
|
64
|
+
|
|
65
|
+
// where payment starts
|
|
66
|
+
import { usePaymentBlock } from "@techdentalkart/features/website";
|
|
67
|
+
const { blocked, message } = usePaymentBlock();
|
|
68
|
+
```
|
|
69
|
+
No fetch in the browser for rules: flags are resolved once per request on the server and travel down with the
|
|
70
|
+
render. Server components and handlers call `await getFlags()`. Reading a flag that an experiment decided reports
|
|
71
|
+
the exposure once the component is on screen (never during server rendering).
|
|
72
|
+
|
|
73
|
+
## What each app declares, in the package
|
|
74
|
+
|
|
75
|
+
| Entry | Sends to the rules (context keys) | Reads back (typed hooks) |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `@techdentalkart/features/website` | `anonymousId userId email isLoggedIn isStaff isBot customerType speciality groupCode noOfOrders pincode district state platform deviceType instadent` via `websiteContext(facts)` | `usePricingVariant()`, `useMarkedUpPrice(price)`, `usePaymentBlock()`, `useStockTicker()` |
|
|
78
|
+
| `@techdentalkart/features/institute-management` | `targetingKey userId email isLoggedIn isStaff role collegeId collegeSlug departmentId isDemo platform` via `instituteContext(session)` | `useApprovals()`, `useRequisitions()`, `useMaintenanceBanner()` |
|
|
79
|
+
|
|
80
|
+
Adding a flag to an app = one hook in that app's entry, then use it. The rule that decides who gets it is
|
|
81
|
+
written in the admin panel.
|
|
82
|
+
|
|
83
|
+
## NestJS (customer-service, cart-service and the other services): one line
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// app.module.ts
|
|
87
|
+
FeaturesModule.forRoot({
|
|
88
|
+
context: (req) => contextFromCustomer(req.customer, req.headers), // ~40 lines, lives in features.context.ts
|
|
89
|
+
controller: "customer/api/v1/features", // GET → { flags, ttlSeconds, evaluatedAt, exposure }
|
|
90
|
+
guards: [OptionalRESTAuthGuard], // anonymous callers get defaults
|
|
91
|
+
}) // also POST customer/api/v1/exposures for the app
|
|
92
|
+
```
|
|
93
|
+
```ts
|
|
94
|
+
constructor(private readonly features: FeaturesService) {}
|
|
95
|
+
if (await this.features.isEnabled("checkout.v2", req)) { ... }
|
|
96
|
+
const f = await this.features.get("pricing.markup", req); // { on, value, experimentId?, variationId? }
|
|
97
|
+
```
|
|
98
|
+
`get` / `isEnabled` are a *use* of the flag (the service decides something with it), so an experiment variant they
|
|
99
|
+
return is reported as an exposure. `all` is a hand-off to the app and reports nothing.
|
|
100
|
+
|
|
101
|
+
## React Native (the app): one provider
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
<FeaturesProvider fetch={() => api.get("/features")} storage={AsyncStorage}
|
|
105
|
+
exposure={{ endpoint: `${API}/exposures`, unit: deviceId, unitType: "deviceId", platform: Platform.OS, surface: "mobile-app" }}
|
|
106
|
+
resume={(cb) => { const s = AppState.addEventListener("change", (st) => st === "active" && cb()); return () => s.remove(); }}>
|
|
107
|
+
```
|
|
108
|
+
`useFlag("checkout.v2").on` in screens. Refreshes every 5 minutes and on foreground, keeps the last good answer.
|
|
109
|
+
|
|
110
|
+
## Plain Node (scripts, jobs)
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { setupFeatures, features } from "@techdentalkart/features";
|
|
114
|
+
setupFeatures(); // FEATURES_* variables decide
|
|
115
|
+
await features.get("area.feature", { targetingKey: "job" });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Configuration: environment variables on the task
|
|
119
|
+
|
|
120
|
+
| Variable | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `FEATURES_SOURCE` | `growthbook` on real tasks; `file` on a laptop; unset = everything off, no network (logged as an error on a production task) |
|
|
123
|
+
| `FEATURES_API_HOST` | the experiments engine, e.g. `https://adminapis.dentalkart.com/experiments` (laptop: `http://localhost:3100`, see `local-engine/`) |
|
|
124
|
+
| `FEATURES_CLIENT_KEY` | this surface + environment's key from the admin's Feature Management settings |
|
|
125
|
+
| `FEATURES_ENV` | `staging` or `production`, stamped on exposures |
|
|
126
|
+
| `FEATURES_SURFACE` | `website`, `institute-management`, `customer-service`, `mobile-app`, stamped on exposures |
|
|
127
|
+
| `FEATURES_EXPOSURES_SECRET` | shared secret for server → engine exposure batches; unset = exposures are not sent |
|
|
128
|
+
| `FEATURES_FILE` | for `file`: path to a JSON of `{ "area.feature": true | 3 | "text" | { "enabled": true, "message": "..." } }` |
|
|
129
|
+
|
|
130
|
+
Rules are fetched once a minute per process and evaluated in memory: zero network per request. The last good
|
|
131
|
+
rules are also kept on disk, so a task that starts while the engine is down serves the previous rules.
|
|
132
|
+
|
|
133
|
+
## Context keys a rule may use
|
|
134
|
+
|
|
135
|
+
Same names everywhere; a rule written for the website works in the app if the app sends the same key.
|
|
136
|
+
|
|
137
|
+
`anonymousId` · `userId` · `email` · `isLoggedIn` · `isStaff` · `isBot` · `platform` · `appVersion` · `appBuild` ·
|
|
138
|
+
`deviceId` · `deviceType` · `instadent` · `customerType` · `speciality` · `groupCode` · `noOfOrders` ·
|
|
139
|
+
`isCreditEligible` · `hasGstin` · `createdAt` · `gender` · `registrationType` · `role` · `collegeId` ·
|
|
140
|
+
`pincode` · `district` · `state` · `city` · `country` · `checkoutPincode`
|
|
141
|
+
|
|
142
|
+
Rollouts and experiments are hashed on `anonymousId` (the `dk_vid` cookie on the web, the device id in the
|
|
143
|
+
app), so a visitor keeps the same variant before and after signing in. `customerType` and `speciality` are free
|
|
144
|
+
text in the database: trim, collapse spaces and lowercase them before sending; rules are written in lowercase.
|
|
145
|
+
|
|
146
|
+
## Naming
|
|
147
|
+
|
|
148
|
+
- Flags: `area.feature`, lowercase, one decision per flag: `checkout.pincode_offer`, `ims.approvals`, `pricing.markup`.
|
|
149
|
+
- Attributes of JSON flags: lowercase, one word where possible: `message`, `audience`, `limit`.
|
|
150
|
+
- Reserved keys on a Flag: `on`, `value`, `experimentId`, `variationId`.
|
|
151
|
+
|
|
152
|
+
## On a laptop
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
cd local-engine && docker compose up -d && ./setup.sh # the real engine + its admin API on :3100
|
|
156
|
+
FEATURES_SOURCE=growthbook FEATURES_API_HOST=http://localhost:3100 FEATURES_CLIENT_KEY=<website staging key> npm run dev
|
|
157
|
+
```
|
|
158
|
+
or `FEATURES_SOURCE=file FEATURES_FILE=./features.local.json` for a plain file with no rules.
|
|
159
|
+
On staging, `?flags=area.feature:on` forces a flag for one request when the middleware forwards it as the
|
|
160
|
+
`x-flags-override` header. Off on live.
|
|
161
|
+
|
|
162
|
+
## Adding a flag
|
|
163
|
+
|
|
164
|
+
1. Create it in the admin panel, default off. A flag nothing reads is harmless.
|
|
165
|
+
2. Use it: `useFlag("area.feature")` or `features.get("area.feature", ctx)`. Render nothing when off.
|
|
166
|
+
3. Merge. Nothing changes until a rule turns it on.
|
|
167
|
+
4. When the feature is permanent, delete the check and then the flag.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The facts a rule may look at. Same names in every repo and in every rule in the admin's
|
|
3
|
+
* Feature Management screens. Build it once per request in your repo's `lib/features.ts`
|
|
4
|
+
* (or the service's context builder); nothing else in the repo touches it.
|
|
5
|
+
*/
|
|
6
|
+
export interface FeatureContext {
|
|
7
|
+
/** the unit rollouts and experiments are hashed on: the first-party anonymous id (dk_vid cookie, app device id) */
|
|
8
|
+
anonymousId?: string;
|
|
9
|
+
/** legacy alias: used as the hash unit when anonymousId is absent */
|
|
10
|
+
targetingKey?: string;
|
|
11
|
+
userId?: string;
|
|
12
|
+
email?: string;
|
|
13
|
+
isLoggedIn?: boolean;
|
|
14
|
+
isStaff?: boolean;
|
|
15
|
+
platform?: "web" | "android" | "ios" | "ims" | string;
|
|
16
|
+
appVersion?: string;
|
|
17
|
+
appBuild?: number;
|
|
18
|
+
/** Institute Management */
|
|
19
|
+
role?: string;
|
|
20
|
+
collegeId?: string;
|
|
21
|
+
/** customers (website, app), named exactly as customer-service sends them */
|
|
22
|
+
customerType?: string;
|
|
23
|
+
speciality?: string;
|
|
24
|
+
groupCode?: string;
|
|
25
|
+
noOfOrders?: number;
|
|
26
|
+
isCreditEligible?: boolean;
|
|
27
|
+
hasGstin?: boolean;
|
|
28
|
+
createdAt?: string;
|
|
29
|
+
gender?: string;
|
|
30
|
+
registrationType?: string;
|
|
31
|
+
/** place: where the visitor browses from */
|
|
32
|
+
country?: string;
|
|
33
|
+
state?: string;
|
|
34
|
+
district?: string;
|
|
35
|
+
city?: string;
|
|
36
|
+
pincode?: string;
|
|
37
|
+
/** place: the shipping address chosen at checkout (only the cart service knows it) */
|
|
38
|
+
checkoutPincode?: string;
|
|
39
|
+
/** device */
|
|
40
|
+
deviceId?: string;
|
|
41
|
+
deviceType?: "desktop" | "mobile" | "phone" | "tablet" | string;
|
|
42
|
+
instadent?: boolean;
|
|
43
|
+
/** any other scalar a surface wants to expose to rules; objects are dropped before evaluation */
|
|
44
|
+
[key: string]: string | number | boolean | undefined;
|
|
45
|
+
}
|
|
46
|
+
export type Scalar = string | number | boolean;
|
|
47
|
+
/**
|
|
48
|
+
* One flag as every repo sees it: `on`, the raw `value` for number/text flags, the attributes of a
|
|
49
|
+
* JSON flag when it is on, and `experimentId` / `variationId` when an experiment decided it.
|
|
50
|
+
* Unknown flag, engine down, source off: `{ on: false }`. Never undefined, never throws.
|
|
51
|
+
*/
|
|
52
|
+
export type Flag = {
|
|
53
|
+
on: boolean;
|
|
54
|
+
value?: Scalar;
|
|
55
|
+
experimentId?: string;
|
|
56
|
+
variationId?: number;
|
|
57
|
+
} & Record<string, Scalar | undefined>;
|
|
58
|
+
/** Every flag the visitor can see, keyed by `area.feature`. Discovered from the engine, never declared in code. */
|
|
59
|
+
export type FlagSet = Record<string, Flag>;
|
|
60
|
+
export declare const OFF: Flag;
|
|
61
|
+
/**
|
|
62
|
+
* Evaluated value -> Flag.
|
|
63
|
+
* boolean -> { on }
|
|
64
|
+
* number -> { on: value !== 0, value }
|
|
65
|
+
* string -> { on: value !== "", value }
|
|
66
|
+
* object -> { on: enabled ?? true, ...scalar attributes } (`enabled` and `_`-prefixed keys dropped)
|
|
67
|
+
* null/undefined -> { on: false }
|
|
68
|
+
*/
|
|
69
|
+
export declare function toFlag(value: unknown): Flag;
|
|
70
|
+
/** The unit an exposure is attributed to, and which kind it is. */
|
|
71
|
+
export declare function unitOf(ctx: FeatureContext): {
|
|
72
|
+
unit: string;
|
|
73
|
+
unitType: string;
|
|
74
|
+
} | null;
|
|
75
|
+
/**
|
|
76
|
+
* FeatureContext -> engine attributes. Undefined values and objects are dropped. `id` is set to the
|
|
77
|
+
* unit so rules that do not name a hash attribute still stick per visitor.
|
|
78
|
+
*/
|
|
79
|
+
export declare function toAttributes(ctx: FeatureContext): Record<string, Scalar>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OFF = void 0;
|
|
4
|
+
exports.toFlag = toFlag;
|
|
5
|
+
exports.unitOf = unitOf;
|
|
6
|
+
exports.toAttributes = toAttributes;
|
|
7
|
+
exports.OFF = Object.freeze({ on: false });
|
|
8
|
+
/**
|
|
9
|
+
* Evaluated value -> Flag.
|
|
10
|
+
* boolean -> { on }
|
|
11
|
+
* number -> { on: value !== 0, value }
|
|
12
|
+
* string -> { on: value !== "", value }
|
|
13
|
+
* object -> { on: enabled ?? true, ...scalar attributes } (`enabled` and `_`-prefixed keys dropped)
|
|
14
|
+
* null/undefined -> { on: false }
|
|
15
|
+
*/
|
|
16
|
+
function toFlag(value) {
|
|
17
|
+
if (typeof value === "boolean")
|
|
18
|
+
return value ? { on: true } : exports.OFF;
|
|
19
|
+
if (typeof value === "number")
|
|
20
|
+
return { on: value !== 0, value };
|
|
21
|
+
if (typeof value === "string")
|
|
22
|
+
return { on: value !== "", value };
|
|
23
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
24
|
+
return exports.OFF;
|
|
25
|
+
const r = value;
|
|
26
|
+
const on = r.enabled === undefined ? true : !!r.enabled;
|
|
27
|
+
if (!on)
|
|
28
|
+
return exports.OFF;
|
|
29
|
+
const out = { on: true };
|
|
30
|
+
for (const [k, v] of Object.entries(r)) {
|
|
31
|
+
if (k === "enabled" || k.startsWith("_"))
|
|
32
|
+
continue;
|
|
33
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
34
|
+
out[k] = v;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/** The unit an exposure is attributed to, and which kind it is. */
|
|
39
|
+
function unitOf(ctx) {
|
|
40
|
+
if (ctx.anonymousId)
|
|
41
|
+
return { unit: String(ctx.anonymousId), unitType: "anonymousId" };
|
|
42
|
+
if (ctx.targetingKey)
|
|
43
|
+
return { unit: String(ctx.targetingKey), unitType: "targetingKey" };
|
|
44
|
+
if (ctx.userId)
|
|
45
|
+
return { unit: String(ctx.userId), unitType: "userId" };
|
|
46
|
+
if (ctx.deviceId)
|
|
47
|
+
return { unit: String(ctx.deviceId), unitType: "deviceId" };
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* FeatureContext -> engine attributes. Undefined values and objects are dropped. `id` is set to the
|
|
52
|
+
* unit so rules that do not name a hash attribute still stick per visitor.
|
|
53
|
+
*/
|
|
54
|
+
function toAttributes(ctx) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [k, v] of Object.entries(ctx)) {
|
|
57
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
58
|
+
out[k] = v;
|
|
59
|
+
}
|
|
60
|
+
const unit = unitOf(ctx);
|
|
61
|
+
if (unit && out.id === undefined)
|
|
62
|
+
out.id = unit.unit;
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Scalar } from "./context";
|
|
2
|
+
/** One "this visitor saw this variant" event. What the results are computed from. */
|
|
3
|
+
export interface Exposure {
|
|
4
|
+
experimentId: string;
|
|
5
|
+
variationId: number;
|
|
6
|
+
/** the id the experiment was hashed on */
|
|
7
|
+
unit: string;
|
|
8
|
+
unitType: string;
|
|
9
|
+
/** ISO time the visitor was exposed */
|
|
10
|
+
ts: string;
|
|
11
|
+
platform?: string;
|
|
12
|
+
/** which app reported it: website, institute-management, customer-service, mobile-app */
|
|
13
|
+
surface?: string;
|
|
14
|
+
env?: string;
|
|
15
|
+
/** a few scalar facts for dimension breakdowns; never email or anything personal */
|
|
16
|
+
attrs?: Record<string, Scalar>;
|
|
17
|
+
}
|
|
18
|
+
export type ExposureSender = (batch: Exposure[]) => Promise<void> | void;
|
|
19
|
+
export interface ExposureQueueOptions {
|
|
20
|
+
send: ExposureSender;
|
|
21
|
+
/** how long a batch may wait before it is sent; default 5 s */
|
|
22
|
+
flushMs?: number;
|
|
23
|
+
/** send as soon as this many are waiting; default 100 */
|
|
24
|
+
maxBatch?: number;
|
|
25
|
+
/** the same unit + experiment + variation is reported once per this window; default 24 h */
|
|
26
|
+
dedupeTtlMs?: number;
|
|
27
|
+
/** upper bound on remembered keys; oldest are forgotten first; default 50k */
|
|
28
|
+
maxKeys?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Dedupes and batches exposures, then hands batches to `send`. Never throws into the caller:
|
|
32
|
+
* a failed send is logged once and the batch dropped (an exposure is not worth a request failing).
|
|
33
|
+
* Works in Node, the browser and React Native (timers only).
|
|
34
|
+
*/
|
|
35
|
+
export declare class ExposureQueue {
|
|
36
|
+
private readonly opts;
|
|
37
|
+
private waiting;
|
|
38
|
+
private seen;
|
|
39
|
+
private timer;
|
|
40
|
+
private readonly flushMs;
|
|
41
|
+
private readonly maxBatch;
|
|
42
|
+
private readonly dedupeTtlMs;
|
|
43
|
+
private readonly maxKeys;
|
|
44
|
+
private warned;
|
|
45
|
+
constructor(opts: ExposureQueueOptions);
|
|
46
|
+
/** Queue one exposure. Returns false when it was already reported inside the dedupe window. */
|
|
47
|
+
record(e: Exposure): boolean;
|
|
48
|
+
get size(): number;
|
|
49
|
+
/** Send everything waiting now. */
|
|
50
|
+
flush(): Promise<void>;
|
|
51
|
+
stop(): void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Sender that POSTs `{ exposures: [...] }` as JSON. On the server: to the engine with the shared
|
|
55
|
+
* secret. In the browser / app: to the surface's own route (e.g. `/api/exposures`), no secret.
|
|
56
|
+
*/
|
|
57
|
+
export declare function httpSender(url: string, secret?: string, timeoutMs?: number): ExposureSender;
|
|
58
|
+
/** Basic shape check for a batch received over HTTP from a browser or app. */
|
|
59
|
+
export declare function parseExposureBatch(body: unknown, max?: number): Exposure[];
|
|
Binary file
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { OFF, toFlag, type FeatureContext, type Flag, type FlagSet } from "./context";
|
|
2
|
+
import { ExposureQueue, httpSender, parseExposureBatch, type Exposure, type ExposureSender } from "./exposures";
|
|
3
|
+
import { FileProvider } from "./providers/file";
|
|
4
|
+
import { GrowthBookProvider } from "./providers/growthbook";
|
|
5
|
+
import { OffProvider } from "./providers/off";
|
|
6
|
+
export type { FeatureContext, Flag, FlagSet, Scalar } from "./context";
|
|
7
|
+
export type { Exposure, ExposureSender } from "./exposures";
|
|
8
|
+
export { GrowthBookProvider, FileProvider, OffProvider, ExposureQueue, httpSender, parseExposureBatch, OFF, toFlag };
|
|
9
|
+
export interface SetupOptions {
|
|
10
|
+
/** growthbook | file | off. Default FEATURES_SOURCE, else "off": a task without it makes no network calls. */
|
|
11
|
+
source?: "growthbook" | "file" | "off";
|
|
12
|
+
/** the experiments engine; default FEATURES_API_HOST */
|
|
13
|
+
apiHost?: string;
|
|
14
|
+
/** this surface + environment's key; default FEATURES_CLIENT_KEY */
|
|
15
|
+
clientKey?: string;
|
|
16
|
+
/** staging | production, stamped on exposures; default FEATURES_ENV */
|
|
17
|
+
environment?: string;
|
|
18
|
+
/** which app this is (website, institute-management, customer-service, mobile-app); default FEATURES_SURFACE */
|
|
19
|
+
surface?: string;
|
|
20
|
+
/** for source=file; default FEATURES_FILE or ./features.local.json */
|
|
21
|
+
file?: string;
|
|
22
|
+
/** rules refresh interval in ms; default 60 000 */
|
|
23
|
+
refreshMs?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Where server-side exposures go. Default: POST `${apiHost}/dentalkart/exposures` with
|
|
26
|
+
* FEATURES_EXPOSURES_SECRET when that variable is set; nothing otherwise. Pass a function to
|
|
27
|
+
* capture them yourself (tests), or false to disable.
|
|
28
|
+
*/
|
|
29
|
+
exposures?: ExposureSender | {
|
|
30
|
+
url: string;
|
|
31
|
+
secret?: string;
|
|
32
|
+
} | false;
|
|
33
|
+
log?: (msg: string) => void;
|
|
34
|
+
}
|
|
35
|
+
/** Call once at startup. Idempotent. Never throws: on any problem falls back to "off". */
|
|
36
|
+
export declare function setupFeatures(opts?: SetupOptions): void;
|
|
37
|
+
/** Report that a visitor was actually shown an experiment variant. Deduped, batched, never throws. */
|
|
38
|
+
export declare function recordExposure(flag: Flag, ctx: FeatureContext, attrs?: Record<string, string | number | boolean>): boolean;
|
|
39
|
+
/** Forward a batch that a browser or app posted to this server's own exposures route. */
|
|
40
|
+
export declare function forwardExposures(body: unknown): Promise<number>;
|
|
41
|
+
/** Send whatever is waiting (call on shutdown). */
|
|
42
|
+
export declare const flushExposures: () => Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The API every repo uses. Flag names are `area.feature`. Every answer is safe: a missing flag,
|
|
45
|
+
* a dead engine or source=off gives `{ on: false }` / `false` / `{}`.
|
|
46
|
+
*
|
|
47
|
+
* `get` and `isEnabled` are a *use* of the flag (a service deciding something), so an experiment
|
|
48
|
+
* variant they return is reported as an exposure. `all` is a hand-off to a page or an app; the
|
|
49
|
+
* browser reports the exposure when a component actually reads the flag.
|
|
50
|
+
*/
|
|
51
|
+
export declare const features: {
|
|
52
|
+
get: (name: string, ctx?: FeatureContext) => Promise<Flag>;
|
|
53
|
+
isEnabled: (name: string, ctx?: FeatureContext) => Promise<boolean>;
|
|
54
|
+
/** Every flag for one context, no exposures. Nothing in a repo has to list flag names to get them here. */
|
|
55
|
+
all: (ctx?: FeatureContext) => Promise<FlagSet>;
|
|
56
|
+
/** Which source is active: growthbook | file | off. */
|
|
57
|
+
source: () => string;
|
|
58
|
+
};
|
|
59
|
+
export type { Exposure as ExposureEvent };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.features = exports.flushExposures = exports.toFlag = exports.OFF = exports.parseExposureBatch = exports.httpSender = exports.ExposureQueue = exports.OffProvider = exports.FileProvider = exports.GrowthBookProvider = void 0;
|
|
4
|
+
exports.setupFeatures = setupFeatures;
|
|
5
|
+
exports.recordExposure = recordExposure;
|
|
6
|
+
exports.forwardExposures = forwardExposures;
|
|
7
|
+
const context_1 = require("./context");
|
|
8
|
+
Object.defineProperty(exports, "OFF", { enumerable: true, get: function () { return context_1.OFF; } });
|
|
9
|
+
Object.defineProperty(exports, "toFlag", { enumerable: true, get: function () { return context_1.toFlag; } });
|
|
10
|
+
const exposures_1 = require("./exposures");
|
|
11
|
+
Object.defineProperty(exports, "ExposureQueue", { enumerable: true, get: function () { return exposures_1.ExposureQueue; } });
|
|
12
|
+
Object.defineProperty(exports, "httpSender", { enumerable: true, get: function () { return exposures_1.httpSender; } });
|
|
13
|
+
Object.defineProperty(exports, "parseExposureBatch", { enumerable: true, get: function () { return exposures_1.parseExposureBatch; } });
|
|
14
|
+
const file_1 = require("./providers/file");
|
|
15
|
+
Object.defineProperty(exports, "FileProvider", { enumerable: true, get: function () { return file_1.FileProvider; } });
|
|
16
|
+
const growthbook_1 = require("./providers/growthbook");
|
|
17
|
+
Object.defineProperty(exports, "GrowthBookProvider", { enumerable: true, get: function () { return growthbook_1.GrowthBookProvider; } });
|
|
18
|
+
const off_1 = require("./providers/off");
|
|
19
|
+
Object.defineProperty(exports, "OffProvider", { enumerable: true, get: function () { return off_1.OffProvider; } });
|
|
20
|
+
let provider = new off_1.OffProvider();
|
|
21
|
+
let queue = null;
|
|
22
|
+
let stamp = {};
|
|
23
|
+
/** Call once at startup. Idempotent. Never throws: on any problem falls back to "off". */
|
|
24
|
+
function setupFeatures(opts = {}) {
|
|
25
|
+
const env = process.env;
|
|
26
|
+
const source = opts.source ?? env.FEATURES_SOURCE ?? "off";
|
|
27
|
+
const log = opts.log ?? ((m) => console.warn(m));
|
|
28
|
+
provider.stop();
|
|
29
|
+
queue?.stop();
|
|
30
|
+
queue = null;
|
|
31
|
+
stamp = { surface: opts.surface ?? env.FEATURES_SURFACE, env: opts.environment ?? env.FEATURES_ENV };
|
|
32
|
+
try {
|
|
33
|
+
if (source === "growthbook") {
|
|
34
|
+
const apiHost = opts.apiHost ?? env.FEATURES_API_HOST;
|
|
35
|
+
const clientKey = opts.clientKey ?? env.FEATURES_CLIENT_KEY;
|
|
36
|
+
if (!apiHost || !clientKey)
|
|
37
|
+
throw new Error("FEATURES_API_HOST and FEATURES_CLIENT_KEY are required for source=growthbook");
|
|
38
|
+
provider = new growthbook_1.GrowthBookProvider({ apiHost, clientKey, refreshMs: opts.refreshMs, log });
|
|
39
|
+
const sender = resolveSender(opts.exposures, apiHost, env.FEATURES_EXPOSURES_URL, env.FEATURES_EXPOSURES_SECRET);
|
|
40
|
+
if (sender)
|
|
41
|
+
queue = new exposures_1.ExposureQueue({ send: sender });
|
|
42
|
+
}
|
|
43
|
+
else if (source === "file") {
|
|
44
|
+
provider = new file_1.FileProvider(opts.file ?? env.FEATURES_FILE ?? "features.local.json");
|
|
45
|
+
const sender = typeof opts.exposures === "function" ? opts.exposures : null;
|
|
46
|
+
if (sender)
|
|
47
|
+
queue = new exposures_1.ExposureQueue({ send: sender });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
provider = new off_1.OffProvider();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
log(`[features] setup failed (${err?.message ?? err}); every flag is off`);
|
|
55
|
+
provider = new off_1.OffProvider();
|
|
56
|
+
}
|
|
57
|
+
if (source === "off" && env.NODE_ENV === "production") {
|
|
58
|
+
log("[features] FEATURES_SOURCE is not set on a production task: every flag is off");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function resolveSender(given, apiHost, urlFromEnv, secretFromEnv) {
|
|
62
|
+
if (given === false)
|
|
63
|
+
return null;
|
|
64
|
+
if (typeof given === "function")
|
|
65
|
+
return given;
|
|
66
|
+
if (given && typeof given === "object")
|
|
67
|
+
return (0, exposures_1.httpSender)(given.url, given.secret);
|
|
68
|
+
if (urlFromEnv)
|
|
69
|
+
return (0, exposures_1.httpSender)(urlFromEnv, secretFromEnv);
|
|
70
|
+
if (secretFromEnv)
|
|
71
|
+
return (0, exposures_1.httpSender)(`${apiHost.replace(/\/+$/, "")}/dentalkart/exposures`, secretFromEnv);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/** Report that a visitor was actually shown an experiment variant. Deduped, batched, never throws. */
|
|
75
|
+
function recordExposure(flag, ctx, attrs) {
|
|
76
|
+
if (!queue || flag.experimentId === undefined || flag.variationId === undefined)
|
|
77
|
+
return false;
|
|
78
|
+
const unit = (0, context_1.unitOf)(ctx);
|
|
79
|
+
if (!unit)
|
|
80
|
+
return false;
|
|
81
|
+
return queue.record({
|
|
82
|
+
experimentId: flag.experimentId,
|
|
83
|
+
variationId: flag.variationId,
|
|
84
|
+
unit: unit.unit,
|
|
85
|
+
unitType: unit.unitType,
|
|
86
|
+
ts: new Date().toISOString(),
|
|
87
|
+
platform: typeof ctx.platform === "string" ? ctx.platform : undefined,
|
|
88
|
+
surface: stamp.surface,
|
|
89
|
+
env: stamp.env,
|
|
90
|
+
attrs: attrs ?? pickDimensions(ctx),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Forward a batch that a browser or app posted to this server's own exposures route. */
|
|
94
|
+
async function forwardExposures(body) {
|
|
95
|
+
if (!queue)
|
|
96
|
+
return 0;
|
|
97
|
+
let n = 0;
|
|
98
|
+
for (const e of (0, exposures_1.parseExposureBatch)(body)) {
|
|
99
|
+
if (queue.record({ ...e, surface: e.surface ?? stamp.surface, env: e.env ?? stamp.env }))
|
|
100
|
+
n++;
|
|
101
|
+
}
|
|
102
|
+
return n;
|
|
103
|
+
}
|
|
104
|
+
/** Send whatever is waiting (call on shutdown). */
|
|
105
|
+
const flushExposures = () => queue?.flush() ?? Promise.resolve();
|
|
106
|
+
exports.flushExposures = flushExposures;
|
|
107
|
+
const DIMENSIONS = ["platform", "deviceType", "customerType", "speciality", "groupCode", "state", "district", "instadent"];
|
|
108
|
+
function pickDimensions(ctx) {
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const k of DIMENSIONS) {
|
|
111
|
+
const v = ctx[k];
|
|
112
|
+
if (v !== undefined)
|
|
113
|
+
out[k] = v;
|
|
114
|
+
}
|
|
115
|
+
if (typeof ctx.pincode === "string" && ctx.pincode.length >= 3)
|
|
116
|
+
out.pincodePrefix = ctx.pincode.slice(0, 3);
|
|
117
|
+
return Object.keys(out).length ? out : undefined;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The API every repo uses. Flag names are `area.feature`. Every answer is safe: a missing flag,
|
|
121
|
+
* a dead engine or source=off gives `{ on: false }` / `false` / `{}`.
|
|
122
|
+
*
|
|
123
|
+
* `get` and `isEnabled` are a *use* of the flag (a service deciding something), so an experiment
|
|
124
|
+
* variant they return is reported as an exposure. `all` is a hand-off to a page or an app; the
|
|
125
|
+
* browser reports the exposure when a component actually reads the flag.
|
|
126
|
+
*/
|
|
127
|
+
exports.features = {
|
|
128
|
+
get: async (name, ctx = {}) => {
|
|
129
|
+
try {
|
|
130
|
+
await provider.ready();
|
|
131
|
+
const flag = provider.get(name, ctx);
|
|
132
|
+
recordExposure(flag, ctx);
|
|
133
|
+
return flag;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return context_1.OFF;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
isEnabled: async (name, ctx = {}) => (await exports.features.get(name, ctx)).on,
|
|
140
|
+
/** Every flag for one context, no exposures. Nothing in a repo has to list flag names to get them here. */
|
|
141
|
+
all: async (ctx = {}) => {
|
|
142
|
+
try {
|
|
143
|
+
await provider.ready();
|
|
144
|
+
return provider.all(ctx);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
/** Which source is active: growthbook | file | off. */
|
|
151
|
+
source: () => provider.name,
|
|
152
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FeatureContext } from "../context";
|
|
2
|
+
/** The session fields a rule may target. Pass the iron-session data as is. */
|
|
3
|
+
export interface InstituteSession {
|
|
4
|
+
userId?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
role?: string;
|
|
7
|
+
collegeId?: string;
|
|
8
|
+
collegeSlug?: string;
|
|
9
|
+
departmentId?: string | null;
|
|
10
|
+
isDemo?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** InstituteSession -> FeatureContext. Keys a rule sees: targetingKey, userId, email, isStaff, role,
|
|
13
|
+
* collegeId, collegeSlug, departmentId, isDemo, platform. */
|
|
14
|
+
export declare function instituteContext(s: InstituteSession): FeatureContext;
|
|
15
|
+
export declare const IMS_FLAGS: {
|
|
16
|
+
/** entitlement: the order-approval flow */
|
|
17
|
+
readonly approvals: "ims.approvals";
|
|
18
|
+
/** release: internal requisitions */
|
|
19
|
+
readonly requisitions: "ims.requisitions";
|
|
20
|
+
/** kill switch: a banner with `message` on every page */
|
|
21
|
+
readonly maintenanceBanner: "ims.maintenance_banner";
|
|
22
|
+
};
|
|
23
|
+
export declare function useApprovals(): boolean;
|
|
24
|
+
export declare function useRequisitions(): boolean;
|
|
25
|
+
export declare function useMaintenanceBanner(): {
|
|
26
|
+
on: boolean;
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IMS_FLAGS = void 0;
|
|
4
|
+
exports.instituteContext = instituteContext;
|
|
5
|
+
exports.useApprovals = useApprovals;
|
|
6
|
+
exports.useRequisitions = useRequisitions;
|
|
7
|
+
exports.useMaintenanceBanner = useMaintenanceBanner;
|
|
8
|
+
// No "use client" here on purpose: the context builder runs on the server inside getFlags(), and a
|
|
9
|
+
// client-marked module would turn it into a client reference that cannot be called there. The hooks
|
|
10
|
+
// below still work in client components, which import this module across the boundary themselves.
|
|
11
|
+
/**
|
|
12
|
+
* Institute Management's declarations: what it sends to the rules, and what it reads back.
|
|
13
|
+
* This is the only place flag names for Institute Management are written down.
|
|
14
|
+
*
|
|
15
|
+
* Engine key: `institute-management (production)` (the only place it runs).
|
|
16
|
+
*/
|
|
17
|
+
const react_1 = require("../react");
|
|
18
|
+
/** InstituteSession -> FeatureContext. Keys a rule sees: targetingKey, userId, email, isStaff, role,
|
|
19
|
+
* collegeId, collegeSlug, departmentId, isDemo, platform. */
|
|
20
|
+
function instituteContext(s) {
|
|
21
|
+
return {
|
|
22
|
+
targetingKey: s.userId,
|
|
23
|
+
userId: s.userId,
|
|
24
|
+
isLoggedIn: !!s.userId,
|
|
25
|
+
platform: "ims",
|
|
26
|
+
role: s.role,
|
|
27
|
+
collegeId: s.collegeId,
|
|
28
|
+
collegeSlug: s.collegeSlug,
|
|
29
|
+
departmentId: s.departmentId ?? undefined,
|
|
30
|
+
email: s.email,
|
|
31
|
+
isStaff: !!s.email?.endsWith("@dentalkart.com"),
|
|
32
|
+
isDemo: !!s.isDemo,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
exports.IMS_FLAGS = {
|
|
36
|
+
/** entitlement: the order-approval flow */
|
|
37
|
+
approvals: "ims.approvals",
|
|
38
|
+
/** release: internal requisitions */
|
|
39
|
+
requisitions: "ims.requisitions",
|
|
40
|
+
/** kill switch: a banner with `message` on every page */
|
|
41
|
+
maintenanceBanner: "ims.maintenance_banner",
|
|
42
|
+
};
|
|
43
|
+
function useApprovals() { return (0, react_1.useFlag)(exports.IMS_FLAGS.approvals).on; }
|
|
44
|
+
function useRequisitions() { return (0, react_1.useFlag)(exports.IMS_FLAGS.requisitions).on; }
|
|
45
|
+
function useMaintenanceBanner() {
|
|
46
|
+
const f = (0, react_1.useFlag)(exports.IMS_FLAGS.maintenanceBanner);
|
|
47
|
+
return { on: f.on, message: (typeof f.message === "string" && f.message) || "Some features are temporarily unavailable." };
|
|
48
|
+
}
|