@ogygia/flags 0.11.0 → 0.13.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/README.md +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.js +115 -32
- package/dist/messages.d.ts +29 -0
- package/dist/messages.js +84 -0
- package/dist/ofrep.js +18 -3
- package/dist/openfeature.js +4 -1
- package/dist/server.js +4 -2
- package/dist/source-health.d.ts +8 -0
- package/dist/source-health.js +29 -0
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -31,4 +31,4 @@ hero(c); // 'control' | 'bold', sticky per visitor
|
|
|
31
31
|
|
|
32
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
33
|
|
|
34
|
-
Docs: [Flags & experiments](https://ogygiajs.dev/docs/
|
|
34
|
+
Docs: [@ogygia/flags](https://ogygiajs.dev/docs/flags/overview), with the full [Flags & experiments](https://ogygiajs.dev/docs/flags/guide) guide.
|
package/dist/index.d.ts
CHANGED
|
@@ -58,14 +58,19 @@ export interface DecideOptions {
|
|
|
58
58
|
* decide({ source: openfeature(client), overrides: (c) => !!c.visitor?.qa, exposure: send });
|
|
59
59
|
*/
|
|
60
60
|
export declare function decide(opts: DecideOptions): void;
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
63
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Send the queued exposures to the sink now. Called at each request's end (the flags plugin's `done`)
|
|
63
|
+
* and by the size and age caps. Resolves when they have reached the sink (behind the batches before
|
|
64
|
+
* them); `undefined` when nothing is queued or being sent. A sink failure is contained: a lost batch
|
|
65
|
+
* is never a request's problem.
|
|
66
|
+
*/
|
|
67
|
+
export declare function flush_exposures(): Promise<void> | undefined;
|
|
64
68
|
/**
|
|
65
69
|
* Resolve the source ONCE per request over every registered flag, caching decisions on the
|
|
66
70
|
* Request so reads stay sync. Awaited by the flags plugin's `request` stage (flags/server.ts), above
|
|
67
71
|
* 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
|
|
72
|
+
* degrades every flag to its native rule, it does not take the request down, and is said
|
|
73
|
+
* (`flags_source_failed`, once per failing streak).
|
|
69
74
|
*/
|
|
70
75
|
export declare function prime_flags(c: CtxLike): Promise<void>;
|
|
71
76
|
/** A per-request component chooser for `page()` — `page(hero.pick({ control: A, bold: B }))`.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { flags_app } from "./app.js";
|
|
2
|
+
import { warn } from "./messages.js";
|
|
3
|
+
import { source_answered, source_failed, source_mark, source_reset } from "./source-health.js";
|
|
2
4
|
import { BROWSER, DEV } from "esm-env";
|
|
3
5
|
import { fnv1a32 } from "@ogygia/core/plugin/utils";
|
|
6
|
+
import { machine, outbox, policy } from "@ogygia/machine";
|
|
4
7
|
// src/index.ts
|
|
5
8
|
/**
|
|
6
9
|
* `flag()` — the ONE assignment primitive. A kill switch, a rollout, a targeting rule, and an
|
|
@@ -20,8 +23,10 @@ import { fnv1a32 } from "@ogygia/core/plugin/utils";
|
|
|
20
23
|
*/
|
|
21
24
|
/** Written by an upstream shell's identity hop (the fragment feature); absent = the tier is skipped. */
|
|
22
25
|
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.
|
|
26
|
+
/** Per-request source resolution, cached on the Request (GC'd with it): its {@link Prime}. */
|
|
27
|
+
const RESOLVED = Symbol.for("ogygia.flags.resolved.v2");
|
|
28
|
+
const prime_of = (c) => c.request[RESOLVED];
|
|
29
|
+
const set_prime = (c, p) => c.request[RESOLVED] = p;
|
|
25
30
|
/** 0..9999 — sticky hash bucket for (salt, visitor). Uniform + deterministic, not cryptographic;
|
|
26
31
|
* the shared pure hash keeps this module safe in ANY bundle (runtime + every island entry). */
|
|
27
32
|
const pct = (s) => fnv1a32(s) % 1e4;
|
|
@@ -52,8 +57,6 @@ let _batch = {
|
|
|
52
57
|
max: 25,
|
|
53
58
|
ms: 2e3
|
|
54
59
|
};
|
|
55
|
-
let _queue = [];
|
|
56
|
-
let _timer = null;
|
|
57
60
|
/**
|
|
58
61
|
* Configure flags for this server, once (hooks.server.ts / any server module). Everything is
|
|
59
62
|
* optional — with no `decide()` at all, flags are native, sticky, and zero-JS.
|
|
@@ -61,7 +64,10 @@ let _timer = null;
|
|
|
61
64
|
* decide({ source: openfeature(client), overrides: (c) => !!c.visitor?.qa, exposure: send });
|
|
62
65
|
*/
|
|
63
66
|
function decide(opts) {
|
|
64
|
-
if ("source" in opts)
|
|
67
|
+
if ("source" in opts) {
|
|
68
|
+
_source = opts.source ?? null;
|
|
69
|
+
source_reset();
|
|
70
|
+
}
|
|
65
71
|
if (opts.overrides) _override_gate = opts.overrides;
|
|
66
72
|
if ("exposure" in opts) _exposure = opts.exposure ?? null;
|
|
67
73
|
if (opts.batch) _batch = {
|
|
@@ -69,23 +75,75 @@ function decide(opts) {
|
|
|
69
75
|
ms: opts.batch.ms ?? 2e3
|
|
70
76
|
};
|
|
71
77
|
}
|
|
78
|
+
const _exposures = /* @__PURE__ */ (/* @__PURE__ */ machine()({
|
|
79
|
+
name: "flags exposures",
|
|
80
|
+
initial: "idle",
|
|
81
|
+
steps: {
|
|
82
|
+
idle: ["buffering"],
|
|
83
|
+
buffering: ["idle"]
|
|
84
|
+
}
|
|
85
|
+
})).create();
|
|
86
|
+
/** What the next batch takes. */
|
|
87
|
+
let _queue = [];
|
|
88
|
+
/** The last batch was given up: the next one given up is not said again until one is taken. */
|
|
89
|
+
let _sink_failing = false;
|
|
90
|
+
/** The last batch put, until it settles: the request's end waits for it (the outbox sends in
|
|
91
|
+
* order, so every batch before it has settled by then). */
|
|
92
|
+
let _last_sent = null;
|
|
93
|
+
/** The batches on their way to the sink: kept alive past the response, one at a time, in order. */
|
|
94
|
+
const _batches = /* @__PURE__ */ outbox({
|
|
95
|
+
name: "flags exposures",
|
|
96
|
+
send: async (batch) => {
|
|
97
|
+
await _exposure?.(batch);
|
|
98
|
+
_sink_failing = false;
|
|
99
|
+
},
|
|
100
|
+
retry: /* @__PURE__ */ policy({
|
|
101
|
+
attempts: 3,
|
|
102
|
+
backoff: [250, 1e3],
|
|
103
|
+
jitter: .2,
|
|
104
|
+
deadline: 1e4
|
|
105
|
+
}),
|
|
106
|
+
inOrder: true,
|
|
107
|
+
giveUp: (_batch, error) => {
|
|
108
|
+
if (_sink_failing) return;
|
|
109
|
+
_sink_failing = true;
|
|
110
|
+
warn("flags_exposure_failed", error);
|
|
111
|
+
},
|
|
112
|
+
waitUntil: (work) => flags_app()?.keepAlive(work)
|
|
113
|
+
});
|
|
72
114
|
function emit_exposure(e) {
|
|
73
|
-
if (!_exposure) return;
|
|
115
|
+
if (BROWSER || !_exposure) return;
|
|
74
116
|
_queue.push(e);
|
|
75
|
-
if (_queue.length >= _batch.max)
|
|
76
|
-
|
|
117
|
+
if (_queue.length >= _batch.max) {
|
|
118
|
+
send();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (_exposures.now.kind === "buffering") return;
|
|
122
|
+
_exposures.go("buffering");
|
|
123
|
+
const timer = setTimeout(() => void send(), _batch.ms);
|
|
124
|
+
_exposures.scope.defer(() => clearTimeout(timer));
|
|
77
125
|
}
|
|
78
|
-
/**
|
|
79
|
-
*
|
|
80
|
-
function
|
|
81
|
-
if (_timer) clearTimeout(_timer);
|
|
82
|
-
_timer = null;
|
|
83
|
-
if (_queue.length === 0) return;
|
|
126
|
+
/** Everything queued, as one batch into the outbox now (buffering → idle). Settles once the sink
|
|
127
|
+
* took it or it was given up; never rejects. */
|
|
128
|
+
function send() {
|
|
84
129
|
const batch = _queue;
|
|
85
130
|
_queue = [];
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
131
|
+
_exposures.move("buffering", "idle");
|
|
132
|
+
const sent = _batches.put(batch).then(() => {
|
|
133
|
+
if (_last_sent === sent) _last_sent = null;
|
|
134
|
+
});
|
|
135
|
+
_last_sent = sent;
|
|
136
|
+
return sent;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Send the queued exposures to the sink now. Called at each request's end (the flags plugin's `done`)
|
|
140
|
+
* and by the size and age caps. Resolves when they have reached the sink (behind the batches before
|
|
141
|
+
* them); `undefined` when nothing is queued or being sent. A sink failure is contained: a lost batch
|
|
142
|
+
* is never a request's problem.
|
|
143
|
+
*/
|
|
144
|
+
function flush_exposures() {
|
|
145
|
+
if (_queue.length > 0) return send();
|
|
146
|
+
return _last_sent ?? void 0;
|
|
89
147
|
}
|
|
90
148
|
/** Every constructed flag self-registers here — the complete name list a source is asked for, and
|
|
91
149
|
* the set federation auto-carries. Import your flags (route modules do this at startup; on plain
|
|
@@ -95,15 +153,32 @@ const _registry = /* @__PURE__ */ new Map();
|
|
|
95
153
|
* Resolve the source ONCE per request over every registered flag, caching decisions on the
|
|
96
154
|
* Request so reads stay sync. Awaited by the flags plugin's `request` stage (flags/server.ts), above
|
|
97
155
|
* 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
|
|
156
|
+
* degrades every flag to its native rule, it does not take the request down, and is said
|
|
157
|
+
* (`flags_source_failed`, once per failing streak).
|
|
99
158
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (_registry.size === 0)
|
|
159
|
+
function prime_flags(c) {
|
|
160
|
+
if (!_source) return Promise.resolve();
|
|
161
|
+
const now = prime_of(c);
|
|
162
|
+
if (now !== void 0) return now.kind === "priming" ? now.done : Promise.resolve();
|
|
163
|
+
if (_registry.size === 0) {
|
|
164
|
+
set_prime(c, {
|
|
165
|
+
kind: "resolved",
|
|
166
|
+
map: {}
|
|
167
|
+
});
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
}
|
|
170
|
+
const claim = {
|
|
171
|
+
kind: "priming",
|
|
172
|
+
done: Promise.resolve()
|
|
173
|
+
};
|
|
174
|
+
set_prime(c, claim);
|
|
175
|
+
claim.done = ask_source(c, _source);
|
|
176
|
+
return claim.done;
|
|
177
|
+
}
|
|
178
|
+
async function ask_source(c, source) {
|
|
179
|
+
const mark = source_mark();
|
|
105
180
|
try {
|
|
106
|
-
const out = await
|
|
181
|
+
const out = await source([..._registry.values()], {
|
|
107
182
|
...c,
|
|
108
183
|
visitor: visitor_of(c)
|
|
109
184
|
});
|
|
@@ -114,18 +189,26 @@ async function prime_flags(c) {
|
|
|
114
189
|
const norm = typeof raw === "boolean" ? { variant: raw ? "on" : "off" } : typeof raw === "string" ? { variant: raw } : raw;
|
|
115
190
|
if (norm && q.variants.includes(norm.variant)) map[q.name] = norm;
|
|
116
191
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
192
|
+
set_prime(c, {
|
|
193
|
+
kind: "resolved",
|
|
194
|
+
map
|
|
195
|
+
});
|
|
196
|
+
source_answered(mark);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
set_prime(c, {
|
|
199
|
+
kind: "resolved",
|
|
200
|
+
map: {}
|
|
201
|
+
});
|
|
202
|
+
source_failed(error);
|
|
120
203
|
}
|
|
121
204
|
}
|
|
122
205
|
function resolved_for(c, name) {
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
206
|
+
const p = prime_of(c);
|
|
207
|
+
if (p === void 0 && _source && !_unprimed_warned) {
|
|
125
208
|
_unprimed_warned = true;
|
|
126
|
-
|
|
209
|
+
warn("flags_source_unprimed");
|
|
127
210
|
}
|
|
128
|
-
return map
|
|
211
|
+
return p?.kind === "resolved" ? p.map[name] : void 0;
|
|
129
212
|
}
|
|
130
213
|
let _unprimed_warned = false;
|
|
131
214
|
let _unstarted_warned = false;
|
|
@@ -204,7 +287,7 @@ function flag(name, shape, opts = {}) {
|
|
|
204
287
|
if (facts.perVisitor === null) facts.perVisitor = `flag:${name}`;
|
|
205
288
|
} else if (!BROWSER && !_unstarted_warned) {
|
|
206
289
|
_unstarted_warned = true;
|
|
207
|
-
|
|
290
|
+
warn("flags_plugin_not_running");
|
|
208
291
|
}
|
|
209
292
|
const cached = memo.get(c.request);
|
|
210
293
|
if (cached !== void 0) return cached;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MessagesOf } from "@ogygia/core/internal/messages";
|
|
2
|
+
/** The errors: `throw new OgygiaError(code, args)`. */
|
|
3
|
+
export declare const FLAGS_ERRORS: {};
|
|
4
|
+
/** The warnings: `warn(code, args, ...rest)`. */
|
|
5
|
+
export declare const FLAGS_WARNINGS: {
|
|
6
|
+
flags_source_unprimed: {
|
|
7
|
+
text: () => string;
|
|
8
|
+
details: () => string;
|
|
9
|
+
};
|
|
10
|
+
flags_exposure_failed: {
|
|
11
|
+
text: () => string;
|
|
12
|
+
details: () => string;
|
|
13
|
+
};
|
|
14
|
+
flags_source_failed: {
|
|
15
|
+
text: () => string;
|
|
16
|
+
details: () => string;
|
|
17
|
+
};
|
|
18
|
+
flags_plugin_not_running: {
|
|
19
|
+
text: () => string;
|
|
20
|
+
details: () => string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
type Own = MessagesOf<typeof FLAGS_ERRORS, typeof FLAGS_WARNINGS>;
|
|
24
|
+
/** `throw new OgygiaError(code, args)`; `warn(code, args, ...rest)` logs; `line(code, args)` is the
|
|
25
|
+
* line as a string. Wording and link in dev and while prerendering, the link alone otherwise. */
|
|
26
|
+
export declare const OgygiaError: Own["OgygiaError"];
|
|
27
|
+
export declare const warn: Own["warn"];
|
|
28
|
+
export declare const line: Own["line"];
|
|
29
|
+
export {};
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { BROWSER, DEV } from "esm-env";
|
|
2
|
+
import { define_messages, dev_or_prerender, prose } from "@ogygia/core/internal/messages";
|
|
3
|
+
// src/messages.ts
|
|
4
|
+
/**
|
|
5
|
+
* EVERY MESSAGE THE FLAGS PACKAGE PRINTS, in one place: its code, its wording, what to do about it
|
|
6
|
+
* (core's `@ogygia/core/internal/messages` says how the system works).
|
|
7
|
+
*
|
|
8
|
+
* warn('flags_plugin_not_running');
|
|
9
|
+
*
|
|
10
|
+
* Flag reads run in the app's server and its pages, so the wording is only passed in dev: a
|
|
11
|
+
* production build prints the code's page alone.
|
|
12
|
+
*/
|
|
13
|
+
/** The errors: `throw new OgygiaError(code, args)`. */
|
|
14
|
+
const FLAGS_ERRORS = {};
|
|
15
|
+
/** The warnings: `warn(code, args, ...rest)`. */
|
|
16
|
+
const FLAGS_WARNINGS = {
|
|
17
|
+
flags_source_unprimed: {
|
|
18
|
+
text: () => prose`
|
|
19
|
+
flags: \`decide({ source })\` is set but no request was primed — list \`flags()\` beside
|
|
20
|
+
\`ogygia()\` in vite.config \`plugins\`.
|
|
21
|
+
`,
|
|
22
|
+
details: () => prose`
|
|
23
|
+
A flag has a \`source\`, but the request it was read in was never primed by the flags
|
|
24
|
+
plugin, so every read ignores the source and falls back to the flag's own decision. Said
|
|
25
|
+
once per server.
|
|
26
|
+
|
|
27
|
+
List \`flags()\` (from \`@ogygia/flags/vite\`) beside \`ogygia()\` in the \`plugins\` of
|
|
28
|
+
vite.config.
|
|
29
|
+
`
|
|
30
|
+
},
|
|
31
|
+
flags_exposure_failed: {
|
|
32
|
+
text: () => prose`
|
|
33
|
+
flags: the exposure sink (\`decide({ exposure })\`) failed, and the batch it was sent is lost.
|
|
34
|
+
The error follows.
|
|
35
|
+
`,
|
|
36
|
+
details: () => prose`
|
|
37
|
+
The function given to \`decide({ exposure })\` threw, or the promise it returned rejected (a
|
|
38
|
+
send that failed), so the exposures in that batch did not reach your analytics. The request
|
|
39
|
+
that read the flags is not affected. Said once, then again only after a send has worked:
|
|
40
|
+
a sink that stays down does not fill the log.
|
|
41
|
+
|
|
42
|
+
Check the sink's endpoint and credentials. To keep a batch through an outage, retry or
|
|
43
|
+
queue inside the sink.
|
|
44
|
+
`
|
|
45
|
+
},
|
|
46
|
+
flags_source_failed: {
|
|
47
|
+
text: () => prose`
|
|
48
|
+
flags: the flag source (\`decide({ source })\`) failed, so its flags fell back to their own
|
|
49
|
+
rule. The error follows.
|
|
50
|
+
`,
|
|
51
|
+
details: () => prose`
|
|
52
|
+
The source given to \`decide({ source })\` threw, rejected, or (with \`ofrep()\`) its
|
|
53
|
+
endpoint answered an error or could not be reached in time; with \`openfeature()\`, one
|
|
54
|
+
flag's evaluation threw. The request is not affected: each flag the source did not answer
|
|
55
|
+
is decided by its own rollout, as if there were no source. Said once, then again only
|
|
56
|
+
after a request the source served worked: a source that stays down does not fill the log.
|
|
57
|
+
|
|
58
|
+
Check the vendor's status, the endpoint and its credentials, and \`ofrep({ timeout })\`.
|
|
59
|
+
`
|
|
60
|
+
},
|
|
61
|
+
flags_plugin_not_running: {
|
|
62
|
+
text: () => prose`
|
|
63
|
+
flags: a flag was read on the server but the flags plugin is not running — list
|
|
64
|
+
\`flags()\` (from \`@ogygia/flags/vite\`) beside \`ogygia()\` in vite.config \`plugins\`.
|
|
65
|
+
Until then flag reads are not reported to the request, and a stored page could keep one
|
|
66
|
+
variant.
|
|
67
|
+
`,
|
|
68
|
+
details: () => prose`
|
|
69
|
+
A flag was read on the server, but the flags plugin never started. Nothing tells the
|
|
70
|
+
request that its page depends on a flag, so a store (a postrender cache) could keep one
|
|
71
|
+
variant of an A/B-tested page and serve it to everyone. Said once per server.
|
|
72
|
+
|
|
73
|
+
List \`flags()\` (from \`@ogygia/flags/vite\`) beside \`ogygia()\` in the \`plugins\` of
|
|
74
|
+
vite.config.
|
|
75
|
+
`
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const own = /* @__PURE__ */ define_messages(DEV || !BROWSER ? FLAGS_ERRORS : null, DEV || !BROWSER ? FLAGS_WARNINGS : null, dev_or_prerender);
|
|
79
|
+
/** `throw new OgygiaError(code, args)`; `warn(code, args, ...rest)` logs; `line(code, args)` is the
|
|
80
|
+
* line as a string. Wording and link in dev and while prerendering, the link alone otherwise. */
|
|
81
|
+
const OgygiaError = own.OgygiaError;
|
|
82
|
+
const warn = own.warn;
|
|
83
|
+
const line = own.line;
|
|
84
|
+
export { FLAGS_ERRORS, FLAGS_WARNINGS, OgygiaError, line, warn };
|
package/dist/ofrep.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
import { request_scope } from "@ogygia/core/internal/server";
|
|
1
2
|
// src/ofrep.ts
|
|
3
|
+
/**
|
|
4
|
+
* `@ogygia/flags/openfeature` (OFREP) — speak the OpenFeature Remote Evaluation Protocol directly, with
|
|
5
|
+
* ZERO vendor SDK. Any OFREP endpoint works (flagd, the OpenFeature operator, a gateway). One
|
|
6
|
+
* bulk POST per request over the app's declared flags; decisions come back as variant names +
|
|
7
|
+
* optional payloads, indistinguishable from native flags after the await.
|
|
8
|
+
*
|
|
9
|
+
* import { decide } from '@ogygia/flags';
|
|
10
|
+
* import { ofrep } from '@ogygia/flags/openfeature';
|
|
11
|
+
* decide({ source: ofrep({ url: 'http://flagd:8016' }) });
|
|
12
|
+
*/
|
|
2
13
|
const TRAILING_SLASH_RE = /\/$/;
|
|
3
14
|
function default_context(c) {
|
|
4
15
|
const sub = c.visitor?.sub;
|
|
@@ -14,6 +25,7 @@ function ofrep(opts) {
|
|
|
14
25
|
const timeout = opts.timeout ?? 800;
|
|
15
26
|
return async (queries, c) => {
|
|
16
27
|
const out = {};
|
|
28
|
+
const ended = request_scope(c.request).signal;
|
|
17
29
|
try {
|
|
18
30
|
const res = await f(endpoint, {
|
|
19
31
|
method: "POST",
|
|
@@ -22,9 +34,9 @@ function ofrep(opts) {
|
|
|
22
34
|
...opts.headers
|
|
23
35
|
},
|
|
24
36
|
body: JSON.stringify({ context: ctx_of(c) }),
|
|
25
|
-
signal: AbortSignal.timeout(timeout)
|
|
37
|
+
signal: AbortSignal.any([AbortSignal.timeout(timeout), ended])
|
|
26
38
|
});
|
|
27
|
-
if (!res.ok)
|
|
39
|
+
if (!res.ok) throw new Error(`OFREP ${endpoint} answered ${res.status}`);
|
|
28
40
|
const body = await res.json();
|
|
29
41
|
const want = new Map(queries.map((q) => [q.name, q]));
|
|
30
42
|
for (const f of body.flags ?? []) {
|
|
@@ -37,7 +49,10 @@ function ofrep(opts) {
|
|
|
37
49
|
value: f.value
|
|
38
50
|
} : variant;
|
|
39
51
|
}
|
|
40
|
-
} catch {
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (ended.aborted) return out;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
41
56
|
return out;
|
|
42
57
|
};
|
|
43
58
|
}
|
package/dist/openfeature.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { source_failed } from "./source-health.js";
|
|
1
2
|
import { ofrep } from "./ofrep.js";
|
|
2
3
|
// src/openfeature.ts
|
|
3
4
|
function default_context(c) {
|
|
@@ -31,7 +32,9 @@ function openfeature(client, opts = {}) {
|
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
|
-
} catch {
|
|
35
|
+
} catch (error) {
|
|
36
|
+
source_failed(error);
|
|
37
|
+
}
|
|
35
38
|
}));
|
|
36
39
|
return out;
|
|
37
40
|
};
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,9 @@ import { defineServer } from "@ogygia/core/plugin";
|
|
|
5
5
|
/**
|
|
6
6
|
* The flags plugin's server realm (named by `flags()`).
|
|
7
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)
|
|
8
|
+
* `done`: drain queued exposures at the request's end, always (the serverless-safe tail): the send
|
|
9
|
+
* is returned, so the request's end hands it to the platform's `waitUntil` when there is one (#243);
|
|
10
|
+
* the response never waits for it.
|
|
9
11
|
*/
|
|
10
12
|
const server = defineServer({
|
|
11
13
|
setup(app) {
|
|
@@ -19,7 +21,7 @@ const server = defineServer({
|
|
|
19
21
|
});
|
|
20
22
|
},
|
|
21
23
|
done() {
|
|
22
|
-
flush_exposures();
|
|
24
|
+
return flush_exposures();
|
|
23
25
|
}
|
|
24
26
|
});
|
|
25
27
|
export { server as default };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A failure of the source (all of it, or one flag): said unless its streak was already said. */
|
|
2
|
+
export declare function source_failed(error: unknown): void;
|
|
3
|
+
/** Before a call of the source: what `source_answered` compares against. */
|
|
4
|
+
export declare function source_mark(): number;
|
|
5
|
+
/** The source answered: with no failure since `mark`, its streak ends. */
|
|
6
|
+
export declare function source_answered(mark: number): void;
|
|
7
|
+
/** A new source: no streak. */
|
|
8
|
+
export declare function source_reset(): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { warn } from "./messages.js";
|
|
2
|
+
// src/source-health.ts
|
|
3
|
+
/**
|
|
4
|
+
* Whether the flag source is failing: a failure is said (`flags_source_failed`) once per failing
|
|
5
|
+
* streak, and the streak ends with a request the source served with no failure, like the exposure
|
|
6
|
+
* sink's. `openfeature()` says a failure of one flag here while it still answers the others.
|
|
7
|
+
*/
|
|
8
|
+
let failing = false;
|
|
9
|
+
let failures = 0;
|
|
10
|
+
/** A failure of the source (all of it, or one flag): said unless its streak was already said. */
|
|
11
|
+
function source_failed(error) {
|
|
12
|
+
failures++;
|
|
13
|
+
if (failing) return;
|
|
14
|
+
failing = true;
|
|
15
|
+
warn("flags_source_failed", error);
|
|
16
|
+
}
|
|
17
|
+
/** Before a call of the source: what `source_answered` compares against. */
|
|
18
|
+
function source_mark() {
|
|
19
|
+
return failures;
|
|
20
|
+
}
|
|
21
|
+
/** The source answered: with no failure since `mark`, its streak ends. */
|
|
22
|
+
function source_answered(mark) {
|
|
23
|
+
if (failures === mark) failing = false;
|
|
24
|
+
}
|
|
25
|
+
/** A new source: no streak. */
|
|
26
|
+
function source_reset() {
|
|
27
|
+
failing = false;
|
|
28
|
+
}
|
|
29
|
+
export { source_answered, source_failed, source_mark, source_reset };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygia/flags",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Feature flags and A/B tests for ogygia: one flag() primitive, decided server-side, zero JS",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ab-testing",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"README.md"
|
|
23
23
|
],
|
|
24
24
|
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
25
26
|
"exports": {
|
|
26
27
|
".": {
|
|
27
28
|
"types": "./dist/index.d.ts",
|
|
@@ -34,24 +35,29 @@
|
|
|
34
35
|
"./vite": {
|
|
35
36
|
"types": "./dist/vite.d.ts",
|
|
36
37
|
"default": "./dist/vite.js"
|
|
38
|
+
},
|
|
39
|
+
"./internal/messages": {
|
|
40
|
+
"types": "./dist/messages.d.ts",
|
|
41
|
+
"default": "./dist/messages.js"
|
|
37
42
|
}
|
|
38
43
|
},
|
|
39
44
|
"publishConfig": {
|
|
40
45
|
"access": "public"
|
|
41
46
|
},
|
|
42
47
|
"dependencies": {
|
|
48
|
+
"@ogygia/machine": "^0.13.0",
|
|
43
49
|
"esm-env": "^1.2.2"
|
|
44
50
|
},
|
|
45
51
|
"devDependencies": {
|
|
52
|
+
"@ogygia/config": "0.0.0",
|
|
53
|
+
"@ogygia/core": "0.13.0",
|
|
46
54
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
47
55
|
"tsdown": "^0.23.4",
|
|
48
56
|
"typescript": "^6.0.3",
|
|
49
|
-
"vitest": "^4.1.12"
|
|
50
|
-
"@ogygia/core": "0.11.0",
|
|
51
|
-
"@ogygia/tooling": "0.0.0"
|
|
57
|
+
"vitest": "^4.1.12"
|
|
52
58
|
},
|
|
53
59
|
"peerDependencies": {
|
|
54
|
-
"@ogygia/core": "^0.
|
|
60
|
+
"@ogygia/core": "^0.13.0"
|
|
55
61
|
},
|
|
56
62
|
"scripts": {
|
|
57
63
|
"build": "tsdown",
|