@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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type CanActivate, type DynamicModule, type Type } from "@nestjs/common";
|
|
2
|
+
import { type SetupOptions } from "../index";
|
|
3
|
+
import { type FeatureContext, type Flag, type FlagSet } from "../context";
|
|
4
|
+
export interface NestFeaturesOptions {
|
|
5
|
+
/** Turn the incoming request into the facts a rule may use. The only code a repo writes. */
|
|
6
|
+
context: (req: any) => FeatureContext | Promise<FeatureContext>;
|
|
7
|
+
/** Passed to setupFeatures once. Usually nothing: FEATURES_* variables decide. */
|
|
8
|
+
setup?: SetupOptions;
|
|
9
|
+
/**
|
|
10
|
+
* Serve `GET <controller>` returning `{ flags, ttlSeconds, evaluatedAt, exposure }` for the app / a browser.
|
|
11
|
+
* Give the full path (this repo has no global prefix, e.g. "customer/api/v1/features") or false.
|
|
12
|
+
*/
|
|
13
|
+
controller?: string | false;
|
|
14
|
+
/**
|
|
15
|
+
* Serve `POST <exposures>` that forwards the app's exposure batches to the engine.
|
|
16
|
+
* Default: `<controller>` with the last segment replaced by "exposures"; false to disable.
|
|
17
|
+
*/
|
|
18
|
+
exposures?: string | false;
|
|
19
|
+
/** Guards for those routes, e.g. an optional-auth guard so anonymous callers get defaults. */
|
|
20
|
+
guards?: Type<CanActivate>[];
|
|
21
|
+
/** How long clients may keep the answer. Default 300. */
|
|
22
|
+
ttlSeconds?: number;
|
|
23
|
+
/** Modules the guards or the context builder need (e.g. the AuthModule that provides an auth guard's service). */
|
|
24
|
+
imports?: DynamicModule["imports"];
|
|
25
|
+
}
|
|
26
|
+
/** Inject this anywhere: `constructor(private readonly features: FeaturesService)`. */
|
|
27
|
+
export declare class FeaturesService {
|
|
28
|
+
private readonly options;
|
|
29
|
+
constructor(options: NestFeaturesOptions);
|
|
30
|
+
/** The context for this request, as the repo's builder defines it. Never throws: a failed builder means anonymous. */
|
|
31
|
+
context(req: any): Promise<FeatureContext>;
|
|
32
|
+
/** One flag for this request. A use: an experiment variant returned here is reported as an exposure. */
|
|
33
|
+
get(name: string, req: any): Promise<Flag>;
|
|
34
|
+
isEnabled(name: string, req: any): Promise<boolean>;
|
|
35
|
+
/** Every flag for this request, no exposures (the app reports when it shows something). */
|
|
36
|
+
all(req: any): Promise<FlagSet>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One line in app.module.ts:
|
|
40
|
+
* FeaturesModule.forRoot({ context: (req) => contextFromCustomer(req.customer, req.headers), controller: "customer/api/v1/features", guards: [OptionalRESTAuthGuard] })
|
|
41
|
+
*/
|
|
42
|
+
export declare class FeaturesModule {
|
|
43
|
+
static forRoot(options: NestFeaturesOptions): DynamicModule;
|
|
44
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
var FeaturesModule_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.FeaturesModule = exports.FeaturesService = void 0;
|
|
17
|
+
const common_1 = require("@nestjs/common");
|
|
18
|
+
const index_1 = require("../index");
|
|
19
|
+
const context_1 = require("../context");
|
|
20
|
+
const OPTIONS = "DENTALKART_FEATURES_OPTIONS";
|
|
21
|
+
/** Inject this anywhere: `constructor(private readonly features: FeaturesService)`. */
|
|
22
|
+
let FeaturesService = class FeaturesService {
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
/** The context for this request, as the repo's builder defines it. Never throws: a failed builder means anonymous. */
|
|
27
|
+
async context(req) {
|
|
28
|
+
try {
|
|
29
|
+
return await this.options.context(req);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** One flag for this request. A use: an experiment variant returned here is reported as an exposure. */
|
|
36
|
+
get(name, req) { return this.context(req).then((c) => index_1.features.get(name, c)); }
|
|
37
|
+
isEnabled(name, req) { return this.context(req).then((c) => index_1.features.isEnabled(name, c)); }
|
|
38
|
+
/** Every flag for this request, no exposures (the app reports when it shows something). */
|
|
39
|
+
all(req) { return this.context(req).then((c) => index_1.features.all(c)); }
|
|
40
|
+
};
|
|
41
|
+
exports.FeaturesService = FeaturesService;
|
|
42
|
+
exports.FeaturesService = FeaturesService = __decorate([
|
|
43
|
+
(0, common_1.Injectable)(),
|
|
44
|
+
__metadata("design:paramtypes", [Object])
|
|
45
|
+
], FeaturesService);
|
|
46
|
+
/**
|
|
47
|
+
* One line in app.module.ts:
|
|
48
|
+
* FeaturesModule.forRoot({ context: (req) => contextFromCustomer(req.customer, req.headers), controller: "customer/api/v1/features", guards: [OptionalRESTAuthGuard] })
|
|
49
|
+
*/
|
|
50
|
+
let FeaturesModule = FeaturesModule_1 = class FeaturesModule {
|
|
51
|
+
static forRoot(options) {
|
|
52
|
+
(0, index_1.setupFeatures)(options.setup);
|
|
53
|
+
const controllers = [];
|
|
54
|
+
if (options.controller) {
|
|
55
|
+
let FeaturesController = class FeaturesController {
|
|
56
|
+
constructor(svc) {
|
|
57
|
+
this.svc = svc;
|
|
58
|
+
}
|
|
59
|
+
async list(req) {
|
|
60
|
+
const ctx = await this.svc.context(req);
|
|
61
|
+
const unit = (0, context_1.unitOf)(ctx);
|
|
62
|
+
return {
|
|
63
|
+
flags: await index_1.features.all(ctx),
|
|
64
|
+
ttlSeconds: options.ttlSeconds ?? 300,
|
|
65
|
+
evaluatedAt: new Date().toISOString(),
|
|
66
|
+
// who the caller is for exposure reporting, so the app never has to work it out
|
|
67
|
+
exposure: unit ? { unit: unit.unit, unitType: unit.unitType, platform: ctx.platform } : null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
__decorate([
|
|
72
|
+
(0, common_1.Get)(),
|
|
73
|
+
__param(0, (0, common_1.Req)()),
|
|
74
|
+
__metadata("design:type", Function),
|
|
75
|
+
__metadata("design:paramtypes", [Object]),
|
|
76
|
+
__metadata("design:returntype", Promise)
|
|
77
|
+
], FeaturesController.prototype, "list", null);
|
|
78
|
+
FeaturesController = __decorate([
|
|
79
|
+
(0, common_1.Controller)(options.controller),
|
|
80
|
+
__metadata("design:paramtypes", [FeaturesService])
|
|
81
|
+
], FeaturesController);
|
|
82
|
+
if (options.guards?.length)
|
|
83
|
+
(0, common_1.UseGuards)(...options.guards)(FeaturesController);
|
|
84
|
+
controllers.push(FeaturesController);
|
|
85
|
+
const exposuresPath = options.exposures === undefined ? options.controller.replace(/[^/]+$/, "exposures") : options.exposures;
|
|
86
|
+
if (exposuresPath) {
|
|
87
|
+
let ExposuresController = class ExposuresController {
|
|
88
|
+
async receive(body) {
|
|
89
|
+
await (0, index_1.forwardExposures)(body);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
__decorate([
|
|
93
|
+
(0, common_1.Post)(),
|
|
94
|
+
(0, common_1.HttpCode)(204),
|
|
95
|
+
__param(0, (0, common_1.Body)()),
|
|
96
|
+
__metadata("design:type", Function),
|
|
97
|
+
__metadata("design:paramtypes", [Object]),
|
|
98
|
+
__metadata("design:returntype", Promise)
|
|
99
|
+
], ExposuresController.prototype, "receive", null);
|
|
100
|
+
ExposuresController = __decorate([
|
|
101
|
+
(0, common_1.Controller)(exposuresPath)
|
|
102
|
+
], ExposuresController);
|
|
103
|
+
if (options.guards?.length)
|
|
104
|
+
(0, common_1.UseGuards)(...options.guards)(ExposuresController);
|
|
105
|
+
controllers.push(ExposuresController);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
module: FeaturesModule_1,
|
|
110
|
+
imports: options.imports ?? [],
|
|
111
|
+
controllers,
|
|
112
|
+
providers: [
|
|
113
|
+
{ provide: OPTIONS, useValue: options },
|
|
114
|
+
{ provide: FeaturesService, useFactory: (o) => new FeaturesService(o), inject: [OPTIONS] },
|
|
115
|
+
],
|
|
116
|
+
exports: [FeaturesService],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
exports.FeaturesModule = FeaturesModule;
|
|
121
|
+
exports.FeaturesModule = FeaturesModule = FeaturesModule_1 = __decorate([
|
|
122
|
+
(0, common_1.Global)(),
|
|
123
|
+
(0, common_1.Module)({})
|
|
124
|
+
], FeaturesModule);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type SetupOptions } from "../index";
|
|
3
|
+
import { type FeatureContext, type FlagSet } from "../context";
|
|
4
|
+
/** What a repo's context builder receives: the request's cookies and headers, already read. */
|
|
5
|
+
export interface RequestFacts {
|
|
6
|
+
cookie: (name: string) => string | undefined;
|
|
7
|
+
header: (name: string) => string | undefined;
|
|
8
|
+
}
|
|
9
|
+
export interface NextFeaturesOptions {
|
|
10
|
+
/** Turn this request into the facts a rule may use. The only code a repo writes. */
|
|
11
|
+
context: (req: RequestFacts) => FeatureContext | Promise<FeatureContext>;
|
|
12
|
+
/** Passed to setupFeatures once. Usually nothing: FEATURES_* variables decide. */
|
|
13
|
+
setup?: SetupOptions;
|
|
14
|
+
/**
|
|
15
|
+
* The app's own route that receives browser exposures and forwards them to the engine.
|
|
16
|
+
* Default "/api/exposures"; mount `exposuresRoute` there. Set false to never report from the browser.
|
|
17
|
+
*/
|
|
18
|
+
exposuresPath?: string | false;
|
|
19
|
+
/**
|
|
20
|
+
* QA escape hatch: `?flags=area.feature:on,other.flag:off` forces flags for one request when the
|
|
21
|
+
* middleware forwards it as the `x-flags-override` header. Default: allowed unless NODE_ENV is
|
|
22
|
+
* production AND NEXT_PUBLIC_ENV is "live". Set false to disable everywhere.
|
|
23
|
+
*/
|
|
24
|
+
allowOverrides?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Parse "a.b:on,c.d:off" onto a FlagSet. Exported for tests. */
|
|
27
|
+
export declare function applyOverrides(flags: FlagSet, raw: string | undefined | null): FlagSet;
|
|
28
|
+
/**
|
|
29
|
+
* Everything a Next.js repo needs, from one call in `lib/features.ts`:
|
|
30
|
+
*
|
|
31
|
+
* export const { getFlags, FeaturesProvider, exposuresRoute } = createNextFeatures({ context: async (req) => ({ ... }) });
|
|
32
|
+
*
|
|
33
|
+
* - `getFlags()` every flag for this visitor, resolved once per request (React.cache), never throws
|
|
34
|
+
* - `FeaturesProvider` server component for the root layout; hands the set (and who the visitor is) to the client tree
|
|
35
|
+
* - `exposuresRoute` `export const POST = exposuresRoute;` in app/api/exposures/route.ts
|
|
36
|
+
* Then any client component reads a typed hook from the app's entry (`@dentalkart/features/website`) or
|
|
37
|
+
* `useFlag("area.feature")` from `@dentalkart/features/react`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createNextFeatures(opts: NextFeaturesOptions): {
|
|
40
|
+
getFlags: () => Promise<FlagSet>;
|
|
41
|
+
FeaturesProvider: ({ children }: {
|
|
42
|
+
children: React.ReactNode;
|
|
43
|
+
}) => Promise<React.JSX.Element>;
|
|
44
|
+
exposuresRoute: (request: Request) => Promise<Response>;
|
|
45
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyOverrides = applyOverrides;
|
|
4
|
+
exports.createNextFeatures = createNextFeatures;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const headers_1 = require("next/headers");
|
|
8
|
+
const index_1 = require("../index");
|
|
9
|
+
const context_1 = require("../context");
|
|
10
|
+
const react_2 = require("../react");
|
|
11
|
+
/** Parse "a.b:on,c.d:off" onto a FlagSet. Exported for tests. */
|
|
12
|
+
function applyOverrides(flags, raw) {
|
|
13
|
+
if (!raw)
|
|
14
|
+
return flags;
|
|
15
|
+
const out = { ...flags };
|
|
16
|
+
for (const part of raw.split(",")) {
|
|
17
|
+
const i = part.lastIndexOf(":");
|
|
18
|
+
if (i <= 0)
|
|
19
|
+
continue;
|
|
20
|
+
const name = part.slice(0, i).trim(), state = part.slice(i + 1).trim();
|
|
21
|
+
if (state === "on")
|
|
22
|
+
out[name] = { ...(out[name] ?? {}), on: true };
|
|
23
|
+
else if (state === "off")
|
|
24
|
+
out[name] = { on: false };
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Everything a Next.js repo needs, from one call in `lib/features.ts`:
|
|
30
|
+
*
|
|
31
|
+
* export const { getFlags, FeaturesProvider, exposuresRoute } = createNextFeatures({ context: async (req) => ({ ... }) });
|
|
32
|
+
*
|
|
33
|
+
* - `getFlags()` every flag for this visitor, resolved once per request (React.cache), never throws
|
|
34
|
+
* - `FeaturesProvider` server component for the root layout; hands the set (and who the visitor is) to the client tree
|
|
35
|
+
* - `exposuresRoute` `export const POST = exposuresRoute;` in app/api/exposures/route.ts
|
|
36
|
+
* Then any client component reads a typed hook from the app's entry (`@dentalkart/features/website`) or
|
|
37
|
+
* `useFlag("area.feature")` from `@dentalkart/features/react`.
|
|
38
|
+
*/
|
|
39
|
+
function createNextFeatures(opts) {
|
|
40
|
+
(0, index_1.setupFeatures)(opts.setup);
|
|
41
|
+
const overridesAllowed = opts.allowOverrides ?? !(process.env.NODE_ENV === "production" && process.env.NEXT_PUBLIC_ENV === "live");
|
|
42
|
+
const exposuresPath = opts.exposuresPath === undefined ? "/api/exposures" : opts.exposuresPath;
|
|
43
|
+
const getContext = (0, react_1.cache)(async () => {
|
|
44
|
+
try {
|
|
45
|
+
const c = await (0, headers_1.cookies)();
|
|
46
|
+
const h = await (0, headers_1.headers)();
|
|
47
|
+
const req = { cookie: (n) => c.get(n)?.value, header: (n) => h.get(n) ?? undefined };
|
|
48
|
+
return await opts.context(req);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const getFlags = (0, react_1.cache)(async () => {
|
|
55
|
+
try {
|
|
56
|
+
const ctx = await getContext();
|
|
57
|
+
const flags = await index_1.features.all(ctx);
|
|
58
|
+
if (!overridesAllowed)
|
|
59
|
+
return flags;
|
|
60
|
+
const h = await (0, headers_1.headers)();
|
|
61
|
+
return applyOverrides(flags, h.get("x-flags-override"));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
async function FeaturesProvider({ children }) {
|
|
68
|
+
const [initial, ctx] = await Promise.all([getFlags(), getContext()]);
|
|
69
|
+
const unit = (0, context_1.unitOf)(ctx);
|
|
70
|
+
const exposure = exposuresPath && unit
|
|
71
|
+
? {
|
|
72
|
+
endpoint: exposuresPath,
|
|
73
|
+
unit: unit.unit,
|
|
74
|
+
unitType: unit.unitType,
|
|
75
|
+
platform: typeof ctx.platform === "string" ? ctx.platform : "web",
|
|
76
|
+
attrs: dimensions(ctx),
|
|
77
|
+
}
|
|
78
|
+
: undefined;
|
|
79
|
+
return (0, jsx_runtime_1.jsx)(react_2.FeaturesProvider, { initial: initial, exposure: exposure, children: children });
|
|
80
|
+
}
|
|
81
|
+
/** Route handler: `export const POST = exposuresRoute;` */
|
|
82
|
+
async function exposuresRoute(request) {
|
|
83
|
+
try {
|
|
84
|
+
const body = await request.json();
|
|
85
|
+
await (0, index_1.forwardExposures)(body);
|
|
86
|
+
}
|
|
87
|
+
catch { /* a bad batch is ignored, never an error for the visitor */ }
|
|
88
|
+
return new Response(null, { status: 204 });
|
|
89
|
+
}
|
|
90
|
+
return { getFlags, FeaturesProvider, exposuresRoute };
|
|
91
|
+
}
|
|
92
|
+
function dimensions(ctx) {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const k of ["deviceType", "customerType", "speciality", "groupCode", "state", "district", "instadent"]) {
|
|
95
|
+
const v = ctx[k];
|
|
96
|
+
if (v !== undefined)
|
|
97
|
+
out[k] = v;
|
|
98
|
+
}
|
|
99
|
+
if (typeof ctx.pincode === "string" && ctx.pincode.length >= 3)
|
|
100
|
+
out.pincodePrefix = ctx.pincode.slice(0, 3);
|
|
101
|
+
return Object.keys(out).length ? out : undefined;
|
|
102
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type FeatureContext, type Flag, type FlagSet } from "../context";
|
|
2
|
+
import type { Provider } from "./provider";
|
|
3
|
+
/**
|
|
4
|
+
* Laptop / CI source: flags from a JSON file, re-read when it changes. No rules, no context: what is
|
|
5
|
+
* in the file is what every visitor gets.
|
|
6
|
+
* { "ims.approvals": true, "pricing.markup": 3, "ims.maintenance_banner": { "enabled": true, "message": "..." } }
|
|
7
|
+
*/
|
|
8
|
+
export declare class FileProvider implements Provider {
|
|
9
|
+
private readonly path;
|
|
10
|
+
readonly name = "file";
|
|
11
|
+
private cache;
|
|
12
|
+
constructor(path: string);
|
|
13
|
+
ready(): Promise<void>;
|
|
14
|
+
private readAll;
|
|
15
|
+
get(name: string, _ctx: FeatureContext): Flag;
|
|
16
|
+
all(_ctx: FeatureContext): FlagSet;
|
|
17
|
+
stop(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FileProvider = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const context_1 = require("../context");
|
|
39
|
+
/**
|
|
40
|
+
* Laptop / CI source: flags from a JSON file, re-read when it changes. No rules, no context: what is
|
|
41
|
+
* in the file is what every visitor gets.
|
|
42
|
+
* { "ims.approvals": true, "pricing.markup": 3, "ims.maintenance_banner": { "enabled": true, "message": "..." } }
|
|
43
|
+
*/
|
|
44
|
+
class FileProvider {
|
|
45
|
+
constructor(path) {
|
|
46
|
+
this.path = path;
|
|
47
|
+
this.name = "file";
|
|
48
|
+
this.cache = null;
|
|
49
|
+
}
|
|
50
|
+
async ready() { }
|
|
51
|
+
readAll() {
|
|
52
|
+
try {
|
|
53
|
+
const mtime = fs.statSync(this.path).mtimeMs;
|
|
54
|
+
if (!this.cache || this.cache.mtime !== mtime) {
|
|
55
|
+
this.cache = { mtime, flags: JSON.parse(fs.readFileSync(this.path, "utf8")) };
|
|
56
|
+
}
|
|
57
|
+
return this.cache.flags;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
get(name, _ctx) {
|
|
64
|
+
const flags = this.readAll();
|
|
65
|
+
const v = flags[name] ?? flags[name.replace(/\./g, "_")];
|
|
66
|
+
return v === undefined ? context_1.OFF : (0, context_1.toFlag)(v);
|
|
67
|
+
}
|
|
68
|
+
all(_ctx) {
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [k, v] of Object.entries(this.readAll()))
|
|
71
|
+
out[k.includes(".") ? k : k.replace("_", ".")] = (0, context_1.toFlag)(v);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
stop() { }
|
|
75
|
+
}
|
|
76
|
+
exports.FileProvider = FileProvider;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type FeatureResult } from "@growthbook/growthbook";
|
|
2
|
+
import { type FeatureContext, type Flag, type FlagSet } from "../context";
|
|
3
|
+
import type { Provider } from "./provider";
|
|
4
|
+
export interface GrowthBookProviderOptions {
|
|
5
|
+
/** the experiments engine, e.g. https://adminapis.dentalkart.com/experiments */
|
|
6
|
+
apiHost: string;
|
|
7
|
+
/** one per surface and environment, created in the admin's Feature Management settings */
|
|
8
|
+
clientKey: string;
|
|
9
|
+
/** how often the rules are re-fetched; default 60 s */
|
|
10
|
+
refreshMs?: number;
|
|
11
|
+
/** per-fetch timeout; default 3 s */
|
|
12
|
+
fetchTimeoutMs?: number;
|
|
13
|
+
/** where the last good rules file is kept for cold starts while the engine is down; default os.tmpdir() */
|
|
14
|
+
cacheDir?: string;
|
|
15
|
+
log?: (msg: string) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Rules from the experiments engine, evaluated in memory. One HTTP GET a minute per process, zero
|
|
19
|
+
* network per request. The last good payload is also written to disk so a task that starts while the
|
|
20
|
+
* engine is down still serves the previous rules instead of turning everything off.
|
|
21
|
+
*/
|
|
22
|
+
export declare class GrowthBookProvider implements Provider {
|
|
23
|
+
private readonly opts;
|
|
24
|
+
readonly name = "growthbook";
|
|
25
|
+
readonly endpoint: string;
|
|
26
|
+
readonly cacheFile: string;
|
|
27
|
+
private readonly client;
|
|
28
|
+
private timer;
|
|
29
|
+
private first;
|
|
30
|
+
private loaded;
|
|
31
|
+
private warned;
|
|
32
|
+
constructor(opts: GrowthBookProviderOptions);
|
|
33
|
+
ready(): Promise<void>;
|
|
34
|
+
/** Fetch the rules once. Errors are swallowed: the previous payload stays in force. */
|
|
35
|
+
refresh(): Promise<void>;
|
|
36
|
+
/** Evaluate one flag. Also returns the raw engine result so callers can report an exposure. */
|
|
37
|
+
evaluate(name: string, ctx: FeatureContext): {
|
|
38
|
+
flag: Flag;
|
|
39
|
+
result: FeatureResult | null;
|
|
40
|
+
};
|
|
41
|
+
get(name: string, ctx: FeatureContext): Flag;
|
|
42
|
+
all(ctx: FeatureContext): FlagSet;
|
|
43
|
+
stop(): void;
|
|
44
|
+
private loadFromDisk;
|
|
45
|
+
private saveToDisk;
|
|
46
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.GrowthBookProvider = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const growthbook_1 = require("@growthbook/growthbook");
|
|
41
|
+
const context_1 = require("../context");
|
|
42
|
+
/**
|
|
43
|
+
* Rules from the experiments engine, evaluated in memory. One HTTP GET a minute per process, zero
|
|
44
|
+
* network per request. The last good payload is also written to disk so a task that starts while the
|
|
45
|
+
* engine is down still serves the previous rules instead of turning everything off.
|
|
46
|
+
*/
|
|
47
|
+
class GrowthBookProvider {
|
|
48
|
+
constructor(opts) {
|
|
49
|
+
this.opts = opts;
|
|
50
|
+
this.name = "growthbook";
|
|
51
|
+
this.client = new growthbook_1.GrowthBookClient();
|
|
52
|
+
this.timer = null;
|
|
53
|
+
this.loaded = false;
|
|
54
|
+
this.warned = false;
|
|
55
|
+
this.endpoint = `${opts.apiHost.replace(/\/+$/, "")}/api/features/${encodeURIComponent(opts.clientKey)}`;
|
|
56
|
+
this.cacheFile = path.join(opts.cacheDir ?? os.tmpdir(), `dentalkart-features-${safe(opts.clientKey)}.json`);
|
|
57
|
+
this.loadFromDisk();
|
|
58
|
+
this.first = this.refresh();
|
|
59
|
+
this.timer = setInterval(() => void this.refresh(), opts.refreshMs ?? 60000);
|
|
60
|
+
this.timer.unref?.();
|
|
61
|
+
}
|
|
62
|
+
ready() { return this.first; }
|
|
63
|
+
/** Fetch the rules once. Errors are swallowed: the previous payload stays in force. */
|
|
64
|
+
async refresh() {
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const t = setTimeout(() => controller.abort(), this.opts.fetchTimeoutMs ?? 3000);
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(this.endpoint, { signal: controller.signal, headers: { accept: "application/json" } });
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
throw new Error(`HTTP ${res.status}`);
|
|
71
|
+
const payload = (await res.json());
|
|
72
|
+
if (!payload || typeof payload !== "object" || !("features" in payload))
|
|
73
|
+
throw new Error("not a rules payload");
|
|
74
|
+
await this.client.setPayload(payload);
|
|
75
|
+
this.loaded = true;
|
|
76
|
+
this.warned = false;
|
|
77
|
+
this.saveToDisk(payload);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (!this.warned) {
|
|
81
|
+
this.warned = true;
|
|
82
|
+
this.opts.log?.(`[features] rules fetch failed (${err?.message ?? err}); ${this.loaded ? "using the last good rules" : "everything off until the engine answers"}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(t);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Evaluate one flag. Also returns the raw engine result so callers can report an exposure. */
|
|
90
|
+
evaluate(name, ctx) {
|
|
91
|
+
if (!this.loaded)
|
|
92
|
+
return { flag: context_1.OFF, result: null };
|
|
93
|
+
const scoped = this.client.createScopedInstance({ attributes: (0, context_1.toAttributes)(ctx) });
|
|
94
|
+
const result = scoped.evalFeature(name);
|
|
95
|
+
return { flag: withExperiment((0, context_1.toFlag)(result.value), result), result };
|
|
96
|
+
}
|
|
97
|
+
get(name, ctx) { return this.evaluate(name, ctx).flag; }
|
|
98
|
+
all(ctx) {
|
|
99
|
+
const out = {};
|
|
100
|
+
if (!this.loaded)
|
|
101
|
+
return out;
|
|
102
|
+
const scoped = this.client.createScopedInstance({ attributes: (0, context_1.toAttributes)(ctx) });
|
|
103
|
+
for (const key of Object.keys(this.client.getFeatures())) {
|
|
104
|
+
const result = scoped.evalFeature(key);
|
|
105
|
+
out[key] = withExperiment((0, context_1.toFlag)(result.value), result);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
stop() {
|
|
110
|
+
if (this.timer) {
|
|
111
|
+
clearInterval(this.timer);
|
|
112
|
+
this.timer = null;
|
|
113
|
+
}
|
|
114
|
+
this.client.destroy();
|
|
115
|
+
}
|
|
116
|
+
loadFromDisk() {
|
|
117
|
+
try {
|
|
118
|
+
const payload = JSON.parse(fs.readFileSync(this.cacheFile, "utf8"));
|
|
119
|
+
if (payload && typeof payload === "object" && "features" in payload) {
|
|
120
|
+
this.client.initSync({ payload });
|
|
121
|
+
this.loaded = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch { /* no cache yet */ }
|
|
125
|
+
}
|
|
126
|
+
saveToDisk(payload) {
|
|
127
|
+
try {
|
|
128
|
+
const tmp = `${this.cacheFile}.${process.pid}.tmp`;
|
|
129
|
+
fs.writeFileSync(tmp, JSON.stringify(payload));
|
|
130
|
+
fs.renameSync(tmp, this.cacheFile);
|
|
131
|
+
}
|
|
132
|
+
catch { /* read-only file system: fine */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
exports.GrowthBookProvider = GrowthBookProvider;
|
|
136
|
+
function withExperiment(flag, result) {
|
|
137
|
+
const exp = result.experimentResult;
|
|
138
|
+
if (!exp?.inExperiment || !result.experiment)
|
|
139
|
+
return flag;
|
|
140
|
+
// Result.key is the variation's key; the experiment's tracking key lives on the Experiment itself.
|
|
141
|
+
return { ...flag, experimentId: result.experiment.key, variationId: exp.variationId };
|
|
142
|
+
}
|
|
143
|
+
const safe = (s) => s.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type FeatureContext, type Flag, type FlagSet } from "../context";
|
|
2
|
+
import type { Provider } from "./provider";
|
|
3
|
+
/** Every flag is off. Unit tests, emergencies, and any task without FEATURES_SOURCE. */
|
|
4
|
+
export declare class OffProvider implements Provider {
|
|
5
|
+
readonly name = "off";
|
|
6
|
+
ready(): Promise<void>;
|
|
7
|
+
get(_name: string, _ctx: FeatureContext): Flag;
|
|
8
|
+
all(_ctx: FeatureContext): FlagSet;
|
|
9
|
+
stop(): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OffProvider = void 0;
|
|
4
|
+
const context_1 = require("../context");
|
|
5
|
+
/** Every flag is off. Unit tests, emergencies, and any task without FEATURES_SOURCE. */
|
|
6
|
+
class OffProvider {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.name = "off";
|
|
9
|
+
}
|
|
10
|
+
async ready() { }
|
|
11
|
+
get(_name, _ctx) { return context_1.OFF; }
|
|
12
|
+
all(_ctx) { return {}; }
|
|
13
|
+
stop() { }
|
|
14
|
+
}
|
|
15
|
+
exports.OffProvider = OffProvider;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FeatureContext, Flag, FlagSet } from "../context";
|
|
2
|
+
/** Where flags come from. Evaluation is synchronous once `ready()` has resolved. */
|
|
3
|
+
export interface Provider {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** Resolves once the first rules are loaded (or the attempt gave up). Never rejects. */
|
|
6
|
+
ready(): Promise<void>;
|
|
7
|
+
get(name: string, ctx: FeatureContext): Flag;
|
|
8
|
+
all(ctx: FeatureContext): FlagSet;
|
|
9
|
+
stop(): void;
|
|
10
|
+
}
|