@ogygia/flags 0.0.0 → 0.11.0
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/LICENSE +21 -0
- package/README.md +32 -1
- package/dist/app.d.ts +3 -0
- package/dist/app.js +9 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +274 -0
- package/dist/ofrep.d.ts +15 -0
- package/dist/ofrep.js +44 -0
- package/dist/openfeature.d.ts +23 -0
- package/dist/openfeature.js +39 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +25 -0
- package/dist/vite.d.ts +2 -0
- package/dist/vite.js +14 -0
- package/package.json +60 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Puru Vijayvargia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
1
|
# @ogygia/flags
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Feature flags and A/B tests for [ogygia](https://ogygiajs.dev). A kill switch, a rollout, a targeting rule and an A/B/n test are one primitive, `flag()`. It is decided on the server during SSR, so the chosen variant is simply what renders: no flicker, no client SDK, no JS for the decision.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @ogygia/flags
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// vite.config.ts
|
|
13
|
+
import { ogygia } from 'ogygia/vite';
|
|
14
|
+
import { flags } from '@ogygia/flags/vite';
|
|
15
|
+
|
|
16
|
+
export default defineConfig({
|
|
17
|
+
plugins: [ogygia(), flags(), sveltekit()]
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { flag, decide } from '@ogygia/flags';
|
|
25
|
+
|
|
26
|
+
export const checkout = flag('new-checkout'); // kill switch
|
|
27
|
+
export const hero = flag('hero', { control: 80, bold: 20 }); // weighted variants
|
|
28
|
+
|
|
29
|
+
hero(c); // 'control' | 'bold', sticky per visitor
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A native `flag()` works without the plugin. The plugin primes a `decide({ source })` (OpenFeature, OFREP, or your own function) one time per request, and drains the `exposure` sink at the end of each request. OpenFeature lives at `@ogygia/flags/openfeature`.
|
|
33
|
+
|
|
34
|
+
Docs: [Flags & experiments](https://ogygiajs.dev/docs/app/experiments).
|
package/dist/app.d.ts
ADDED
package/dist/app.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { StandardSchemaV1 } from "@ogygia/core/plugin/utils";
|
|
2
|
+
export type CtxLike = {
|
|
3
|
+
request: Request;
|
|
4
|
+
url: URL;
|
|
5
|
+
cookies?: {
|
|
6
|
+
get(name: string): string | undefined;
|
|
7
|
+
};
|
|
8
|
+
/** The router's ONE identity (`routes(table, { visitor })`) — read first when present. Open
|
|
9
|
+
* shape (matches the router's `Visitor`): a targeting fn reads app claims off it. */
|
|
10
|
+
visitor?: (Record<string, unknown> & {
|
|
11
|
+
sub?: string;
|
|
12
|
+
}) | undefined;
|
|
13
|
+
};
|
|
14
|
+
/** One decided flag from the SOURCE: a variant name (validated against the flag's declared
|
|
15
|
+
* variants), optionally with a vendor-authored value (validated against the flag's `value`
|
|
16
|
+
* schema). A bare boolean/string is accepted too — sugar for `{ variant }`. */
|
|
17
|
+
export type Resolved = {
|
|
18
|
+
variant: string;
|
|
19
|
+
value?: unknown;
|
|
20
|
+
} | string | boolean;
|
|
21
|
+
/** What the source is asked to decide — the app's flags for this request. */
|
|
22
|
+
export interface FlagQuery {
|
|
23
|
+
name: string;
|
|
24
|
+
variants: readonly string[];
|
|
25
|
+
/** `'boolean'` for a kill switch / rollout / targeting flag, `'variant'` for a weighted set. */
|
|
26
|
+
kind: "boolean" | "variant";
|
|
27
|
+
}
|
|
28
|
+
/** A decision source — OpenFeature, OFREP, or any function. Given the app's flags + the request
|
|
29
|
+
* context, return each flag's decision (missing keys fall through to the native rule). Resolved
|
|
30
|
+
* ONCE per request (awaited by `handle()` / the router), so every read stays sync. */
|
|
31
|
+
export type FlagSource = (queries: FlagQuery[], c: CtxLike) => Record<string, Resolved> | Promise<Record<string, Resolved>>;
|
|
32
|
+
export interface ExposureEvent {
|
|
33
|
+
name: string;
|
|
34
|
+
variant: string;
|
|
35
|
+
/** the sticky identity the decision keyed on (undefined = anonymous) */
|
|
36
|
+
sub?: string;
|
|
37
|
+
at: number;
|
|
38
|
+
}
|
|
39
|
+
export interface DecideOptions {
|
|
40
|
+
/** Where decisions come from (a vendor). Omit for pure-native flags (still sticky + zero-JS). */
|
|
41
|
+
source?: FlagSource;
|
|
42
|
+
/** Honor `?og-exp=name:variant` beyond dev — gate on something a visitor can't forge (a session
|
|
43
|
+
* role, a signed QA cookie). Default: dev only. */
|
|
44
|
+
overrides?: (c: CtxLike) => boolean;
|
|
45
|
+
/** Exposure sink — called with a BATCH of events (batching is built in: flushed at `max`
|
|
46
|
+
* events, after `ms`, or at each request's end). A throwing sink never breaks a request. */
|
|
47
|
+
exposure?: (events: ExposureEvent[]) => void | Promise<void>;
|
|
48
|
+
/** Exposure batch size / age caps. Defaults: `max` 25, `ms` 2000. */
|
|
49
|
+
batch?: {
|
|
50
|
+
max?: number;
|
|
51
|
+
ms?: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Configure flags for this server, once (hooks.server.ts / any server module). Everything is
|
|
56
|
+
* optional — with no `decide()` at all, flags are native, sticky, and zero-JS.
|
|
57
|
+
*
|
|
58
|
+
* decide({ source: openfeature(client), overrides: (c) => !!c.visitor?.qa, exposure: send });
|
|
59
|
+
*/
|
|
60
|
+
export declare function decide(opts: DecideOptions): void;
|
|
61
|
+
/** Drain queued exposures to the sink. Called at each request's end (serverless-safe tail) and on
|
|
62
|
+
* the size/age caps. A sink failure is contained — a lost batch is never a request's problem. */
|
|
63
|
+
export declare function flush_exposures(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the source ONCE per request over every registered flag, caching decisions on the
|
|
66
|
+
* Request so reads stay sync. Awaited by the flags plugin's `request` stage (flags/server.ts), above
|
|
67
|
+
* the page / hole split. Idempotent; a no-op without a source. Never throws — a source failure
|
|
68
|
+
* degrades every flag to its native rule, it does not take the request down.
|
|
69
|
+
*/
|
|
70
|
+
export declare function prime_flags(c: CtxLike): Promise<void>;
|
|
71
|
+
/** A per-request component chooser for `page()` — `page(hero.pick({ control: A, bold: B }))`.
|
|
72
|
+
* Branded (not a bare function) because Svelte 5 components ARE functions. */
|
|
73
|
+
export interface ComponentPick {
|
|
74
|
+
__ogpick: (c: CtxLike) => unknown;
|
|
75
|
+
}
|
|
76
|
+
/** Rare knobs (third arg to `flag`). */
|
|
77
|
+
export interface FlagOptions<Value = unknown> {
|
|
78
|
+
/** Mutual-exclusion group. Members sharing a `layer` string split visitors into equal LANES:
|
|
79
|
+
* one sticky hash of (group, visitor) over the sorted member names picks each visitor's
|
|
80
|
+
* OWNER, and every other member answers control before its own rule runs — two experiments
|
|
81
|
+
* on one surface never share a visitor, so their measurements stay attributable. Real
|
|
82
|
+
* exposure = a member's split ÷ member count. Override/carried/source outrank the gate. */
|
|
83
|
+
layer?: string;
|
|
84
|
+
/** Standard Schema for a vendor-authored payload read via `.value(c)`. A vendor value that
|
|
85
|
+
* fails the schema is IGNORED (never enters) — the same trust-boundary law as body schemas. */
|
|
86
|
+
value?: StandardSchemaV1<Value>;
|
|
87
|
+
/** Required alongside `value`: the payload when the source is silent or a value is invalid, so
|
|
88
|
+
* `.value(c)` never returns undefined. */
|
|
89
|
+
fallback?: Value;
|
|
90
|
+
}
|
|
91
|
+
/** The shape a flag is declared with (second arg):
|
|
92
|
+
* - omitted → a kill switch (off until decided on),
|
|
93
|
+
* - a number → sticky rollout percent (0–100) of the feature,
|
|
94
|
+
* - a function → per-request targeting (`true`/`false`, or `undefined` to fall through),
|
|
95
|
+
* - a record → weighted variants (ratios; the first key is control). */
|
|
96
|
+
export type FlagShape<V extends string> = undefined | number | ((c: CtxLike) => boolean | undefined) | Record<V, number>;
|
|
97
|
+
interface FlagBase<Value> {
|
|
98
|
+
readonly name: string;
|
|
99
|
+
/** `name:variant` — for `data-og-exp` stamps, logs, Server-Timing labels. */
|
|
100
|
+
stamp(c: CtxLike): string;
|
|
101
|
+
/** Vendor-authored payload (validated by the `value` schema), else the `fallback`. */
|
|
102
|
+
value(c: CtxLike): Value;
|
|
103
|
+
}
|
|
104
|
+
/** A boolean flag — calling it answers "does this request get the feature?". */
|
|
105
|
+
export interface BoolFlag<Value = unknown> extends FlagBase<Value> {
|
|
106
|
+
(c: CtxLike): boolean;
|
|
107
|
+
readonly variants: readonly ("off" | "on")[];
|
|
108
|
+
/** Page slot (no `c` — the router supplies it): `page(flag.pick({ … }))`. */
|
|
109
|
+
pick(map: Record<"off" | "on", unknown>): ComponentPick;
|
|
110
|
+
/** Pick this visitor's entry from a per-variant map (`'off' | 'on'`), typed + total. */
|
|
111
|
+
pick<T>(c: CtxLike, map: Record<"off" | "on", T>): T;
|
|
112
|
+
}
|
|
113
|
+
/** A weighted-variant flag — calling it answers which variant this request gets. */
|
|
114
|
+
export interface VariantFlag<V extends string, Value = unknown> extends FlagBase<Value> {
|
|
115
|
+
(c: CtxLike): V;
|
|
116
|
+
readonly variants: readonly V[];
|
|
117
|
+
/** Page slot (no `c` — the router supplies it): `page(flag.pick({ … }))`. */
|
|
118
|
+
pick(map: Record<V, unknown>): ComponentPick;
|
|
119
|
+
/** Pick this visitor's entry from a per-variant map, typed + total. */
|
|
120
|
+
pick<T>(c: CtxLike, map: Record<V, T>): T;
|
|
121
|
+
}
|
|
122
|
+
export declare function flag<V extends string, Value = unknown>(name: string, shape: Record<V, number>, opts?: FlagOptions<Value>): VariantFlag<V, Value>;
|
|
123
|
+
export declare function flag<Value = unknown>(name: string, shape?: number | ((c: CtxLike) => boolean | undefined), opts?: FlagOptions<Value>): BoolFlag<Value>;
|
|
124
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { flags_app } from "./app.js";
|
|
2
|
+
import { BROWSER, DEV } from "esm-env";
|
|
3
|
+
import { fnv1a32 } from "@ogygia/core/plugin/utils";
|
|
4
|
+
// src/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* `flag()` — the ONE assignment primitive. A kill switch, a rollout, a targeting rule, and an
|
|
7
|
+
* A/B/n test are the same thing: a name with variants, decided per visitor. This module owns the
|
|
8
|
+
* DECISION only; branching rides `flag.pick()` (a router page slot, or a value map), measurement
|
|
9
|
+
* rides the exposure sink, and vendor integration rides `decide({ source })`.
|
|
10
|
+
*
|
|
11
|
+
* Decision order per request (deterministic, sticky, ZERO-JS — it runs server-side during SSR and
|
|
12
|
+
* the chosen variant is simply what gets rendered, so csr=false pages A/B for free):
|
|
13
|
+
* ?og-exp override (gated) → carried claims (a shell decided upstream) → the SOURCE
|
|
14
|
+
* (OpenFeature / OFREP / any resolver, primed once per request) → the native rule
|
|
15
|
+
* (targeting fn / weights) → the first variant (control / off).
|
|
16
|
+
*
|
|
17
|
+
* Everything the old surface split across `experiment` / `flag` / `layer` / `allowOverrides` /
|
|
18
|
+
* `onExposure` / `batchExposures` / `.bucket` / `.on` / `.stamp` collapses to: `flag()`, calling
|
|
19
|
+
* it, `.pick()`, `.value()`, and one `decide()`.
|
|
20
|
+
*/
|
|
21
|
+
/** Written by an upstream shell's identity hop (the fragment feature); absent = the tier is skipped. */
|
|
22
|
+
const CLAIMS = Symbol.for("ogygia.claims.v1");
|
|
23
|
+
/** Per-request source resolution, cached on the Request (GC'd with it). */
|
|
24
|
+
const RESOLVED = Symbol.for("ogygia.flags.resolved.v1");
|
|
25
|
+
/** 0..9999 — sticky hash bucket for (salt, visitor). Uniform + deterministic, not cryptographic;
|
|
26
|
+
* the shared pure hash keeps this module safe in ANY bundle (runtime + every island entry). */
|
|
27
|
+
const pct = (s) => fnv1a32(s) % 1e4;
|
|
28
|
+
/** Who is this? The router identity (folds in carried claims) when present; raw claims / the
|
|
29
|
+
* `og-vid` cookie (the router's `anonymousVisitor()` shape) otherwise; undefined = anonymous.
|
|
30
|
+
* The ONE rule: a flag read hashes on its `sub`, and the source is primed with it as `c.visitor`. */
|
|
31
|
+
function visitor_of(c) {
|
|
32
|
+
if (c.visitor?.sub) return c.visitor;
|
|
33
|
+
const claims = c.request[CLAIMS];
|
|
34
|
+
if (claims?.sub) return claims;
|
|
35
|
+
const vid = c.cookies?.get("og-vid");
|
|
36
|
+
return vid ? {
|
|
37
|
+
sub: vid,
|
|
38
|
+
anonymous: true
|
|
39
|
+
} : c.visitor;
|
|
40
|
+
}
|
|
41
|
+
/** The sticky hash key. Anonymous → '' (weights degrade to the first variant there). */
|
|
42
|
+
function visitor_id(c) {
|
|
43
|
+
return visitor_of(c)?.sub ?? "";
|
|
44
|
+
}
|
|
45
|
+
function carried(c, name) {
|
|
46
|
+
return c.request[CLAIMS]?.experiments?.[name];
|
|
47
|
+
}
|
|
48
|
+
let _source = null;
|
|
49
|
+
let _override_gate = () => DEV;
|
|
50
|
+
let _exposure = null;
|
|
51
|
+
let _batch = {
|
|
52
|
+
max: 25,
|
|
53
|
+
ms: 2e3
|
|
54
|
+
};
|
|
55
|
+
let _queue = [];
|
|
56
|
+
let _timer = null;
|
|
57
|
+
/**
|
|
58
|
+
* Configure flags for this server, once (hooks.server.ts / any server module). Everything is
|
|
59
|
+
* optional — with no `decide()` at all, flags are native, sticky, and zero-JS.
|
|
60
|
+
*
|
|
61
|
+
* decide({ source: openfeature(client), overrides: (c) => !!c.visitor?.qa, exposure: send });
|
|
62
|
+
*/
|
|
63
|
+
function decide(opts) {
|
|
64
|
+
if ("source" in opts) _source = opts.source ?? null;
|
|
65
|
+
if (opts.overrides) _override_gate = opts.overrides;
|
|
66
|
+
if ("exposure" in opts) _exposure = opts.exposure ?? null;
|
|
67
|
+
if (opts.batch) _batch = {
|
|
68
|
+
max: opts.batch.max ?? 25,
|
|
69
|
+
ms: opts.batch.ms ?? 2e3
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function emit_exposure(e) {
|
|
73
|
+
if (!_exposure) return;
|
|
74
|
+
_queue.push(e);
|
|
75
|
+
if (_queue.length >= _batch.max) flush_exposures();
|
|
76
|
+
else if (!_timer) _timer = setTimeout(flush_exposures, _batch.ms);
|
|
77
|
+
}
|
|
78
|
+
/** Drain queued exposures to the sink. Called at each request's end (serverless-safe tail) and on
|
|
79
|
+
* the size/age caps. A sink failure is contained — a lost batch is never a request's problem. */
|
|
80
|
+
function flush_exposures() {
|
|
81
|
+
if (_timer) clearTimeout(_timer);
|
|
82
|
+
_timer = null;
|
|
83
|
+
if (_queue.length === 0) return;
|
|
84
|
+
const batch = _queue;
|
|
85
|
+
_queue = [];
|
|
86
|
+
try {
|
|
87
|
+
_exposure?.(batch);
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
/** Every constructed flag self-registers here — the complete name list a source is asked for, and
|
|
91
|
+
* the set federation auto-carries. Import your flags (route modules do this at startup; on plain
|
|
92
|
+
* Kit, `import './flags'` in hooks.server.ts to prime the source from the first request). */
|
|
93
|
+
const _registry = /* @__PURE__ */ new Map();
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the source ONCE per request over every registered flag, caching decisions on the
|
|
96
|
+
* Request so reads stay sync. Awaited by the flags plugin's `request` stage (flags/server.ts), above
|
|
97
|
+
* the page / hole split. Idempotent; a no-op without a source. Never throws — a source failure
|
|
98
|
+
* degrades every flag to its native rule, it does not take the request down.
|
|
99
|
+
*/
|
|
100
|
+
async function prime_flags(c) {
|
|
101
|
+
const req = c.request;
|
|
102
|
+
if (!_source || req[RESOLVED] !== void 0) return;
|
|
103
|
+
req[RESOLVED] = {};
|
|
104
|
+
if (_registry.size === 0) return;
|
|
105
|
+
try {
|
|
106
|
+
const out = await _source([..._registry.values()], {
|
|
107
|
+
...c,
|
|
108
|
+
visitor: visitor_of(c)
|
|
109
|
+
});
|
|
110
|
+
const map = {};
|
|
111
|
+
for (const q of _registry.values()) {
|
|
112
|
+
const raw = out?.[q.name];
|
|
113
|
+
if (raw === void 0) continue;
|
|
114
|
+
const norm = typeof raw === "boolean" ? { variant: raw ? "on" : "off" } : typeof raw === "string" ? { variant: raw } : raw;
|
|
115
|
+
if (norm && q.variants.includes(norm.variant)) map[q.name] = norm;
|
|
116
|
+
}
|
|
117
|
+
req[RESOLVED] = map;
|
|
118
|
+
} catch {
|
|
119
|
+
req[RESOLVED] = {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function resolved_for(c, name) {
|
|
123
|
+
const map = c.request[RESOLVED];
|
|
124
|
+
if (map === void 0 && _source && !_unprimed_warned) {
|
|
125
|
+
_unprimed_warned = true;
|
|
126
|
+
console.warn("[ogygia] flags: `decide({ source })` is set but no request was primed — list `flags()` beside `ogygia()` in vite.config `plugins`.");
|
|
127
|
+
}
|
|
128
|
+
return map?.[name];
|
|
129
|
+
}
|
|
130
|
+
let _unprimed_warned = false;
|
|
131
|
+
let _unstarted_warned = false;
|
|
132
|
+
/** Record the decided variant as a request fact: federation carries the set as signed claims. */
|
|
133
|
+
function record_assigned(c, name, variant) {
|
|
134
|
+
const facts = flags_app()?.facts(c.request);
|
|
135
|
+
if (facts) facts.assignments[name] = variant;
|
|
136
|
+
}
|
|
137
|
+
function override(c, name) {
|
|
138
|
+
if (!_override_gate(c)) return void 0;
|
|
139
|
+
for (const raw of c.url.searchParams.getAll("og-exp")) for (const pair of raw.split(",")) {
|
|
140
|
+
const [n, v] = pair.split(":");
|
|
141
|
+
if (n === name && v) return v;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const _layers = /* @__PURE__ */ new Map();
|
|
145
|
+
function layer_member_for(group, v) {
|
|
146
|
+
const members = _layers.get(group);
|
|
147
|
+
if (!members || members.length === 0) return void 0;
|
|
148
|
+
const lanes = [...members].sort();
|
|
149
|
+
return lanes[pct(`layer:${group}:${v}`) % lanes.length];
|
|
150
|
+
}
|
|
151
|
+
function flag(name, shape, opts = {}) {
|
|
152
|
+
const is_record = shape !== null && typeof shape === "object";
|
|
153
|
+
const variants = is_record ? Object.keys(shape) : ["off", "on"];
|
|
154
|
+
const control = variants[0];
|
|
155
|
+
const weights = is_record ? shape : null;
|
|
156
|
+
const rollout = typeof shape === "number" ? shape : null;
|
|
157
|
+
const target = typeof shape === "function" ? shape : null;
|
|
158
|
+
_registry.set(name, {
|
|
159
|
+
name,
|
|
160
|
+
variants,
|
|
161
|
+
kind: is_record ? "variant" : "boolean"
|
|
162
|
+
});
|
|
163
|
+
if (opts.layer) {
|
|
164
|
+
const members = _layers.get(opts.layer) ?? [];
|
|
165
|
+
if (!members.includes(name)) members.push(name);
|
|
166
|
+
_layers.set(opts.layer, members);
|
|
167
|
+
}
|
|
168
|
+
const memo = /* @__PURE__ */ new WeakMap();
|
|
169
|
+
const compute = (c) => {
|
|
170
|
+
const o = override(c, name);
|
|
171
|
+
if (o && variants.includes(o)) return o;
|
|
172
|
+
const carried_v = carried(c, name);
|
|
173
|
+
if (carried_v && variants.includes(carried_v)) return carried_v;
|
|
174
|
+
const src = resolved_for(c, name);
|
|
175
|
+
if (src) return src.variant;
|
|
176
|
+
const v = visitor_id(c);
|
|
177
|
+
if (opts.layer && layer_member_for(opts.layer, v) !== name) return control;
|
|
178
|
+
if (target) {
|
|
179
|
+
const t = target(c);
|
|
180
|
+
if (t !== void 0) return t ? "on" : "off";
|
|
181
|
+
}
|
|
182
|
+
if (weights && v) {
|
|
183
|
+
const total = Object.values(weights).reduce((a, b) => a + (b > 0 ? b : 0), 0);
|
|
184
|
+
if (total > 0) {
|
|
185
|
+
const h = pct(`${name}:${v}`) / 1e4 * total;
|
|
186
|
+
let floor = 0;
|
|
187
|
+
for (const variant of variants) {
|
|
188
|
+
const share = weights[variant] > 0 ? weights[variant] : 0;
|
|
189
|
+
if (h >= floor && h < floor + share) return variant;
|
|
190
|
+
floor += share;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (rollout && v) {
|
|
195
|
+
const share = rollout * 100;
|
|
196
|
+
if (pct(`${name}:${v}`) < share) return "on";
|
|
197
|
+
}
|
|
198
|
+
return control;
|
|
199
|
+
};
|
|
200
|
+
const bucket = (c) => {
|
|
201
|
+
const app = flags_app();
|
|
202
|
+
if (app) {
|
|
203
|
+
const facts = app.facts(c.request);
|
|
204
|
+
if (facts.perVisitor === null) facts.perVisitor = `flag:${name}`;
|
|
205
|
+
} else if (!BROWSER && !_unstarted_warned) {
|
|
206
|
+
_unstarted_warned = true;
|
|
207
|
+
console.warn("[ogygia] flags: a flag was read on the server but the flags plugin is not running — list `flags()` (from `@ogygia/flags/vite`) beside `ogygia()` in vite.config `plugins`. Until then flag reads are not reported to the request, and a stored page could keep one variant.");
|
|
208
|
+
}
|
|
209
|
+
const cached = memo.get(c.request);
|
|
210
|
+
if (cached !== void 0) return cached;
|
|
211
|
+
const variant = compute(c);
|
|
212
|
+
memo.set(c.request, variant);
|
|
213
|
+
record_assigned(c, name, variant);
|
|
214
|
+
emit_exposure({
|
|
215
|
+
name,
|
|
216
|
+
variant,
|
|
217
|
+
sub: c.visitor?.sub,
|
|
218
|
+
at: DEV ? 0 : now()
|
|
219
|
+
});
|
|
220
|
+
return variant;
|
|
221
|
+
};
|
|
222
|
+
const value = (c) => {
|
|
223
|
+
const raw = resolved_for(c, name)?.value;
|
|
224
|
+
if (raw !== void 0 && opts.value) {
|
|
225
|
+
const r = opts.value["~standard"].validate(raw);
|
|
226
|
+
if (r && typeof r.then !== "function") {
|
|
227
|
+
const res = r;
|
|
228
|
+
if (!res.issues) return res.value;
|
|
229
|
+
}
|
|
230
|
+
} else if (raw !== void 0 && !opts.value) return raw;
|
|
231
|
+
return opts.fallback;
|
|
232
|
+
};
|
|
233
|
+
const pick = (a, b) => {
|
|
234
|
+
if (b === void 0) {
|
|
235
|
+
const map = a;
|
|
236
|
+
return { __ogpick: (c) => map[bucket(c)] ?? map[control] };
|
|
237
|
+
}
|
|
238
|
+
return b[bucket(a)] ?? b[control];
|
|
239
|
+
};
|
|
240
|
+
const call = (c) => is_record ? bucket(c) : bucket(c) === "on";
|
|
241
|
+
Object.defineProperties(call, {
|
|
242
|
+
name: {
|
|
243
|
+
value: name,
|
|
244
|
+
enumerable: true
|
|
245
|
+
},
|
|
246
|
+
variants: {
|
|
247
|
+
value: variants,
|
|
248
|
+
enumerable: true
|
|
249
|
+
},
|
|
250
|
+
stamp: {
|
|
251
|
+
value: (c) => `${name}:${bucket(c)}`,
|
|
252
|
+
enumerable: true
|
|
253
|
+
},
|
|
254
|
+
value: {
|
|
255
|
+
value,
|
|
256
|
+
enumerable: true
|
|
257
|
+
},
|
|
258
|
+
pick: {
|
|
259
|
+
value: pick,
|
|
260
|
+
enumerable: true
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return call;
|
|
264
|
+
}
|
|
265
|
+
/** `Date.now()` is unavailable in some sandboxes (workflow scripts); guard it so importing this
|
|
266
|
+
* module never throws there — exposure timestamps are 0 in that case (and in dev). */
|
|
267
|
+
function now() {
|
|
268
|
+
try {
|
|
269
|
+
return Date.now();
|
|
270
|
+
} catch {
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export { decide, flag, flush_exposures, prime_flags };
|
package/dist/ofrep.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CtxLike, FlagSource } from "./index.js";
|
|
2
|
+
export interface OfrepOptions {
|
|
3
|
+
/** OFREP base URL (the adapter calls `<url>/ofrep/v1/evaluate/flags/bulk`). */
|
|
4
|
+
url: string;
|
|
5
|
+
/** Static headers (auth token, tenant) sent with every evaluation. */
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
/** Build the OFREP evaluation context from the request (default: visitor `sub` →
|
|
8
|
+
* `targetingKey` plus every own key of `c.visitor`). */
|
|
9
|
+
context?: (c: CtxLike) => Record<string, unknown>;
|
|
10
|
+
/** Per-request fetch (default global). */
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
/** Abort the evaluation after N ms (default 800 — a flag service must not gate first byte). */
|
|
13
|
+
timeout?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function ofrep(opts: OfrepOptions): FlagSource;
|
package/dist/ofrep.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/ofrep.ts
|
|
2
|
+
const TRAILING_SLASH_RE = /\/$/;
|
|
3
|
+
function default_context(c) {
|
|
4
|
+
const sub = c.visitor?.sub;
|
|
5
|
+
return {
|
|
6
|
+
...c.visitor,
|
|
7
|
+
...sub ? { targetingKey: sub } : {}
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function ofrep(opts) {
|
|
11
|
+
const ctx_of = opts.context ?? default_context;
|
|
12
|
+
const f = opts.fetch ?? fetch;
|
|
13
|
+
const endpoint = opts.url.replace(TRAILING_SLASH_RE, "") + "/ofrep/v1/evaluate/flags/bulk";
|
|
14
|
+
const timeout = opts.timeout ?? 800;
|
|
15
|
+
return async (queries, c) => {
|
|
16
|
+
const out = {};
|
|
17
|
+
try {
|
|
18
|
+
const res = await f(endpoint, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
...opts.headers
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify({ context: ctx_of(c) }),
|
|
25
|
+
signal: AbortSignal.timeout(timeout)
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) return out;
|
|
28
|
+
const body = await res.json();
|
|
29
|
+
const want = new Map(queries.map((q) => [q.name, q]));
|
|
30
|
+
for (const f of body.flags ?? []) {
|
|
31
|
+
const q = want.get(f.key);
|
|
32
|
+
if (!q || f.errorCode) continue;
|
|
33
|
+
const variant = f.variant ?? (typeof f.value === "boolean" ? f.value ? "on" : "off" : void 0);
|
|
34
|
+
if (variant === void 0 || !q.variants.includes(variant)) continue;
|
|
35
|
+
out[f.key] = f.value !== void 0 && typeof f.value !== "boolean" ? {
|
|
36
|
+
variant,
|
|
37
|
+
value: f.value
|
|
38
|
+
} : variant;
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
return out;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export { ofrep };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CtxLike, FlagSource } from "./index.js";
|
|
2
|
+
import { OfrepOptions, ofrep } from "./ofrep.js";
|
|
3
|
+
/** The slice of the OpenFeature server `Client` we use — structural, so any SDK version fits. */
|
|
4
|
+
export interface OpenFeatureClientLike {
|
|
5
|
+
getBooleanValue(flagKey: string, defaultValue: boolean, context?: EvaluationContextLike): Promise<boolean>;
|
|
6
|
+
getStringValue(flagKey: string, defaultValue: string, context?: EvaluationContextLike): Promise<string>;
|
|
7
|
+
/** Optional: variants that carry a config payload read via `.value(c)`. */
|
|
8
|
+
getObjectValue?<T>(flagKey: string, defaultValue: T, context?: EvaluationContextLike): Promise<T>;
|
|
9
|
+
}
|
|
10
|
+
export interface EvaluationContextLike {
|
|
11
|
+
targetingKey?: string;
|
|
12
|
+
[k: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
export interface OpenFeatureOptions {
|
|
15
|
+
/** Build the OpenFeature evaluation context from the request (default: visitor `sub` →
|
|
16
|
+
* `targetingKey`, plus every own key of `c.visitor`). */
|
|
17
|
+
context?: (c: CtxLike) => EvaluationContextLike;
|
|
18
|
+
/** Also pull each flag's object value as the `.value(c)` payload (one extra eval per flag).
|
|
19
|
+
* Off by default — most flags are variant-only. */
|
|
20
|
+
values?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function openfeature(client: OpenFeatureClientLike, opts?: OpenFeatureOptions): FlagSource;
|
|
23
|
+
export { type OfrepOptions, ofrep };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ofrep } from "./ofrep.js";
|
|
2
|
+
// src/openfeature.ts
|
|
3
|
+
function default_context(c) {
|
|
4
|
+
const sub = c.visitor?.sub;
|
|
5
|
+
return {
|
|
6
|
+
...c.visitor,
|
|
7
|
+
...sub ? { targetingKey: sub } : {}
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function openfeature(client, opts = {}) {
|
|
11
|
+
const ctx_of = opts.context ?? default_context;
|
|
12
|
+
return async (queries, c) => {
|
|
13
|
+
const context = ctx_of(c);
|
|
14
|
+
const out = {};
|
|
15
|
+
await Promise.all(queries.map(async (q) => {
|
|
16
|
+
try {
|
|
17
|
+
if (q.kind === "boolean") {
|
|
18
|
+
const on = await client.getBooleanValue(q.name, false, context);
|
|
19
|
+
out[q.name] = on ? "on" : "off";
|
|
20
|
+
} else {
|
|
21
|
+
const variant = await client.getStringValue(q.name, q.variants[0], context);
|
|
22
|
+
out[q.name] = variant;
|
|
23
|
+
}
|
|
24
|
+
if (opts.values && client.getObjectValue) {
|
|
25
|
+
const value = await client.getObjectValue(q.name, void 0, context);
|
|
26
|
+
if (value !== void 0) {
|
|
27
|
+
const prev = out[q.name];
|
|
28
|
+
out[q.name] = {
|
|
29
|
+
variant: typeof prev === "string" ? prev : q.variants[0],
|
|
30
|
+
value
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
35
|
+
}));
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export { ofrep, openfeature };
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { flush_exposures, prime_flags } from "./index.js";
|
|
2
|
+
import { set_flags_app } from "./app.js";
|
|
3
|
+
import { defineServer } from "@ogygia/core/plugin";
|
|
4
|
+
// src/server.ts
|
|
5
|
+
/**
|
|
6
|
+
* The flags plugin's server realm (named by `flags()`).
|
|
7
|
+
* `request`: prime the decision source once, above the page / hole split, so every read stays sync.
|
|
8
|
+
* `done`: drain queued exposures at the request's end, always (the serverless-safe tail).
|
|
9
|
+
*/
|
|
10
|
+
const server = defineServer({
|
|
11
|
+
setup(app) {
|
|
12
|
+
set_flags_app(app);
|
|
13
|
+
},
|
|
14
|
+
async request({ event }) {
|
|
15
|
+
await prime_flags({
|
|
16
|
+
request: event.request,
|
|
17
|
+
url: event.url,
|
|
18
|
+
cookies: event.cookies
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
done() {
|
|
22
|
+
flush_exposures();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
export { server as default };
|
package/dist/vite.d.ts
ADDED
package/dist/vite.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { definePlugin } from "@ogygia/core/vite";
|
|
2
|
+
// src/vite.ts
|
|
3
|
+
/**
|
|
4
|
+
* `flags()` — the flags plugin, listed beside `ogygia()` in vite.config `plugins`. Turns on the
|
|
5
|
+
* per-request source priming and exposure flushing (its server realm); `flag()` itself works without
|
|
6
|
+
* it (native, sticky decisions), but a `decide({ source, exposure })` needs it.
|
|
7
|
+
*/
|
|
8
|
+
function flags() {
|
|
9
|
+
return definePlugin({
|
|
10
|
+
name: "@ogygia/flags",
|
|
11
|
+
server: import.meta.resolve("./server.js")
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export { flags };
|
package/package.json
CHANGED
|
@@ -1,7 +1,61 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
"name": "@ogygia/flags",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Feature flags and A/B tests for ogygia: one flag() primitive, decided server-side, zero JS",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ab-testing",
|
|
7
|
+
"feature-flags",
|
|
8
|
+
"ogygia",
|
|
9
|
+
"openfeature",
|
|
10
|
+
"sveltekit"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://ogygiajs.dev",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Puru Vijayvargia <devpuruvj@gmail.com>",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/ogygiajs/ogygia.git",
|
|
18
|
+
"directory": "packages/flags"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./openfeature": {
|
|
31
|
+
"types": "./dist/openfeature.d.ts",
|
|
32
|
+
"default": "./dist/openfeature.js"
|
|
33
|
+
},
|
|
34
|
+
"./vite": {
|
|
35
|
+
"types": "./dist/vite.d.ts",
|
|
36
|
+
"default": "./dist/vite.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"esm-env": "^1.2.2"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
47
|
+
"tsdown": "^0.23.4",
|
|
48
|
+
"typescript": "^6.0.3",
|
|
49
|
+
"vitest": "^4.1.12",
|
|
50
|
+
"@ogygia/core": "0.11.0",
|
|
51
|
+
"@ogygia/tooling": "0.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@ogygia/core": "^0.11.0"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsdown",
|
|
58
|
+
"check": "tsc -p . && tsc -p test && vitest run",
|
|
59
|
+
"test": "vitest run"
|
|
60
|
+
}
|
|
61
|
+
}
|