@toggld/node 0.1.0 → 0.2.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 +34 -5
- package/dist/{core-zMjaOru1.mjs → core-Bx0PVoEm.mjs} +418 -21
- package/dist/effect.d.mts +12 -4
- package/dist/effect.mjs +1 -1
- package/dist/index.d.mts +14 -3
- package/dist/index.mjs +32 -10
- package/dist/{types-RmWNnA7-.d.mts → types-DItF1mIU.d.mts} +55 -3
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -9,11 +9,18 @@ Two API surfaces ship from one implementation:
|
|
|
9
9
|
- **`@toggld/node/effect`** — the native [Effect](https://effect.website) API:
|
|
10
10
|
the client as a service + `Layer`.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
Two evaluation modes:
|
|
13
|
+
|
|
14
|
+
- **Remote** (default) — every call hits the flag API, which evaluates the flag
|
|
15
|
+
against your context and returns the resolved value (OpenFeature "remote
|
|
16
|
+
evaluation" over the OFREP endpoints).
|
|
17
|
+
- **Local** (`mode: "local"`) — recommended for long-lived servers. Flag
|
|
18
|
+
definitions sync in the background (a snapshot fetch plus a live WebSocket
|
|
19
|
+
stream, with polling fallback) and every evaluation runs **in-process** on
|
|
20
|
+
the bundled engine: microsecond latency, zero server requests per
|
|
21
|
+
evaluation, and changes pushed to your process within milliseconds. Requires
|
|
22
|
+
a secret SDK key (`ffk_…`) — public browser tokens cannot read rulesets. See
|
|
23
|
+
[ADR-0012](../../docs/adr/0012-definition-sync-and-local-evaluation.md).
|
|
17
24
|
|
|
18
25
|
## Install
|
|
19
26
|
|
|
@@ -29,6 +36,7 @@ import { ToggldClient } from "@toggld/node"
|
|
|
29
36
|
const client = await ToggldClient.create({
|
|
30
37
|
sdkKey: process.env.TOGGLD_SDK_KEY!, // an evaluation API key
|
|
31
38
|
endpoint: "https://your-flag-api.example.com",
|
|
39
|
+
mode: "local", // omit for remote evaluation
|
|
32
40
|
})
|
|
33
41
|
|
|
34
42
|
// Typed getters — return the default on any error, never throw.
|
|
@@ -77,10 +85,31 @@ scope.
|
|
|
77
85
|
| ---------------- | --------------------- | ---------------- | ---------------------------------------------------------------- |
|
|
78
86
|
| `sdkKey` | `string` | — | Evaluation (or management) API key; sent as `Bearer`. |
|
|
79
87
|
| `endpoint` | `string` | — | Base URL of the flag API, no trailing path. |
|
|
88
|
+
| `mode` | `"remote" \| "local"` | `"remote"` | Local = background definition sync + in-process engine. |
|
|
89
|
+
| `sync.transport` | `"ws" \| "poll"` | `"ws"` | Live stream (with poll safety net) vs polling only. |
|
|
90
|
+
| `sync.pollIntervalMs` | `number` | `30000` | Snapshot poll cadence; cold start retries faster. |
|
|
91
|
+
| `sync.readyTimeoutMs` | `number` | `5000` | How long `create` waits for the first snapshot. |
|
|
80
92
|
| `defaultContext` | `EvaluationContext` | `{}` | Merged under per-call context (per-call attributes win). |
|
|
81
93
|
| `timeoutMs` | `number` | `5000` | Per-request timeout. |
|
|
82
94
|
| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch (tests, proxies, instrumentation). |
|
|
83
95
|
|
|
96
|
+
In local mode `create` resolves once the first snapshot has synced (or after
|
|
97
|
+
`sync.readyTimeoutMs`); until a snapshot arrives, evaluations return your
|
|
98
|
+
defaults with `errorCode: "PROVIDER_NOT_READY"` and sync keeps retrying.
|
|
99
|
+
`client.ready()` awaits sync explicitly. Updates arrive as WebSocket pushes
|
|
100
|
+
within milliseconds of a management change; on runtimes where the stream is
|
|
101
|
+
unavailable the client polls instead, automatically.
|
|
102
|
+
|
|
103
|
+
### How fast is local mode?
|
|
104
|
+
|
|
105
|
+
`pnpm bench:sync` (from the repo root) runs the same workload with sync off vs
|
|
106
|
+
on — representative numbers on a laptop, loopback server:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
sync OFF (remote OFREP) ~11,600 evals/s p50 2.4 ms
|
|
110
|
+
sync ON (local engine) ~1,090,000 evals/s p50 27 µs (0.6 µs single-caller)
|
|
111
|
+
```
|
|
112
|
+
|
|
84
113
|
## Error semantics
|
|
85
114
|
|
|
86
115
|
Evaluation never throws. On a network failure, timeout, unknown flag, server
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Context, Duration, Effect, Layer, Schema } from "effect";
|
|
1
|
+
import { Context, Deferred, Duration, Effect, Layer, Schema } from "effect";
|
|
2
2
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
|
3
|
+
import { Socket } from "effect/unstable/socket";
|
|
3
4
|
//#region ../spec/src/index.ts
|
|
4
5
|
/** True when `value` is assignable to a flag declared as `type`. */
|
|
5
6
|
const valueMatchesType = (value, type) => {
|
|
@@ -10,6 +11,246 @@ const valueMatchesType = (value, type) => {
|
|
|
10
11
|
case "object": return typeof value === "object" && value !== null;
|
|
11
12
|
}
|
|
12
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* The sync stream's WebSocket subprotocol name. Offered first by clients (the
|
|
16
|
+
* server echoes the first offer back, completing the RFC 6455 negotiation).
|
|
17
|
+
*/
|
|
18
|
+
const SYNC_PROTOCOL = "toggld.sync.v1";
|
|
19
|
+
/**
|
|
20
|
+
* Subprotocol entry carrying the bearer credential on a sync WebSocket
|
|
21
|
+
* upgrade: `toggld.bearer.<token>`. The standard `WebSocket` constructor
|
|
22
|
+
* cannot set an `Authorization` header, so the token rides the
|
|
23
|
+
* `Sec-WebSocket-Protocol` offer instead (a recognized pattern — it stays out
|
|
24
|
+
* of URLs and access logs). Servers accept either this or a real
|
|
25
|
+
* `Authorization: Bearer` header.
|
|
26
|
+
*/
|
|
27
|
+
const SYNC_BEARER_PROTOCOL_PREFIX = "toggld.bearer.";
|
|
28
|
+
const TARGETING_KEY = "targetingKey";
|
|
29
|
+
/** Reads the targeting key from a context, if present and a non-empty string. */
|
|
30
|
+
const targetingKeyOf = (ctx) => {
|
|
31
|
+
const v = ctx[TARGETING_KEY];
|
|
32
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
33
|
+
};
|
|
34
|
+
/** Reads an arbitrary attribute, treating `targetingKey` like any other field. */
|
|
35
|
+
const attributeOf = (ctx, attribute) => Object.prototype.hasOwnProperty.call(ctx, attribute) ? ctx[attribute] : void 0;
|
|
36
|
+
const Resolution = {
|
|
37
|
+
/** A successful resolution. */
|
|
38
|
+
ok: (args) => ({
|
|
39
|
+
value: args.value,
|
|
40
|
+
variant: args.variant,
|
|
41
|
+
reason: args.reason,
|
|
42
|
+
errorCode: void 0,
|
|
43
|
+
errorMessage: void 0,
|
|
44
|
+
flagMetadata: args.flagMetadata ?? {}
|
|
45
|
+
}),
|
|
46
|
+
/** An abnormal resolution carrying an error code and a fallback value. */
|
|
47
|
+
error: (args) => ({
|
|
48
|
+
value: args.value,
|
|
49
|
+
variant: args.variant,
|
|
50
|
+
reason: "ERROR",
|
|
51
|
+
errorCode: args.errorCode,
|
|
52
|
+
errorMessage: args.errorMessage,
|
|
53
|
+
flagMetadata: args.flagMetadata ?? {}
|
|
54
|
+
})
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region ../engine/src/hash.ts
|
|
58
|
+
/**
|
|
59
|
+
* MurmurHash3 (x86, 32-bit). A fast, well-distributed, non-cryptographic hash.
|
|
60
|
+
* Used to deterministically bucket a subject into a percentage rollout slice so
|
|
61
|
+
* that a given subject always lands in the same slice for a given flag/rule.
|
|
62
|
+
*/
|
|
63
|
+
const murmur3 = (key, seed = 0) => {
|
|
64
|
+
const data = new TextEncoder().encode(key);
|
|
65
|
+
const len = data.length;
|
|
66
|
+
const c1 = 3432918353;
|
|
67
|
+
const c2 = 461845907;
|
|
68
|
+
let h1 = seed >>> 0;
|
|
69
|
+
const nblocks = len >>> 2;
|
|
70
|
+
const mul32 = (a, b) => {
|
|
71
|
+
const aHi = a >>> 16 & 65535;
|
|
72
|
+
return ((a & 65535) * b >>> 0) + ((aHi * b & 65535) << 16) >>> 0;
|
|
73
|
+
};
|
|
74
|
+
const rotl32 = (x, r) => (x << r | x >>> 32 - r) >>> 0;
|
|
75
|
+
for (let i = 0; i < nblocks; i++) {
|
|
76
|
+
const o = i * 4;
|
|
77
|
+
let k1 = (data[o] | data[o + 1] << 8 | data[o + 2] << 16 | data[o + 3] << 24) >>> 0;
|
|
78
|
+
k1 = mul32(k1, c1);
|
|
79
|
+
k1 = rotl32(k1, 15);
|
|
80
|
+
k1 = mul32(k1, c2);
|
|
81
|
+
h1 = (h1 ^ k1) >>> 0;
|
|
82
|
+
h1 = rotl32(h1, 13);
|
|
83
|
+
h1 = mul32(h1, 5) + 3864292196 >>> 0;
|
|
84
|
+
}
|
|
85
|
+
let k1 = 0;
|
|
86
|
+
const tail = nblocks * 4;
|
|
87
|
+
const rem = len & 3;
|
|
88
|
+
if (rem === 3) k1 ^= data[tail + 2] << 16;
|
|
89
|
+
if (rem >= 2) k1 ^= data[tail + 1] << 8;
|
|
90
|
+
if (rem >= 1) {
|
|
91
|
+
k1 ^= data[tail];
|
|
92
|
+
k1 = mul32(k1, c1);
|
|
93
|
+
k1 = rotl32(k1, 15);
|
|
94
|
+
k1 = mul32(k1, c2);
|
|
95
|
+
h1 = (h1 ^ k1) >>> 0;
|
|
96
|
+
}
|
|
97
|
+
h1 = (h1 ^ len) >>> 0;
|
|
98
|
+
h1 = (h1 ^ h1 >>> 16) >>> 0;
|
|
99
|
+
h1 = mul32(h1, 2246822507);
|
|
100
|
+
h1 = (h1 ^ h1 >>> 13) >>> 0;
|
|
101
|
+
h1 = mul32(h1, 3266489909);
|
|
102
|
+
h1 = (h1 ^ h1 >>> 16) >>> 0;
|
|
103
|
+
return h1 >>> 0;
|
|
104
|
+
};
|
|
105
|
+
/** Resolution of a bucket: a subject maps to an integer in `[0, BUCKET_SCALE)`. */
|
|
106
|
+
const BUCKET_SCALE = 1e4;
|
|
107
|
+
/**
|
|
108
|
+
* Deterministically maps a subject to a bucket in `[0, BUCKET_SCALE)`. The hash
|
|
109
|
+
* input combines the flag key, rule id, and subject so that the same subject
|
|
110
|
+
* gets independent buckets across different flags/rules (no correlation).
|
|
111
|
+
*/
|
|
112
|
+
const bucketFor = (flagKey, ruleId, subject) => {
|
|
113
|
+
return murmur3(`${flagKey}:${ruleId}:${subject}`) % BUCKET_SCALE;
|
|
114
|
+
};
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region ../engine/src/targeting.ts
|
|
117
|
+
const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
118
|
+
const asString = (v) => typeof v === "string" ? v : void 0;
|
|
119
|
+
/** Evaluates a single condition against the context. */
|
|
120
|
+
const matchCondition = (condition, ctx) => {
|
|
121
|
+
const actual = attributeOf(ctx, condition.attribute);
|
|
122
|
+
const operand = condition.value;
|
|
123
|
+
switch (condition.operator) {
|
|
124
|
+
case "exists": return actual !== void 0 && actual !== null;
|
|
125
|
+
case "notExists": return actual === void 0 || actual === null;
|
|
126
|
+
}
|
|
127
|
+
if (actual === void 0 || actual === null) return false;
|
|
128
|
+
switch (condition.operator) {
|
|
129
|
+
case "eq": return deepEqual(actual, operand ?? null);
|
|
130
|
+
case "ne": return !deepEqual(actual, operand ?? null);
|
|
131
|
+
case "in": return Array.isArray(operand) && operand.some((o) => deepEqual(actual, o));
|
|
132
|
+
case "notIn": return !Array.isArray(operand) || !operand.some((o) => deepEqual(actual, o));
|
|
133
|
+
case "lt":
|
|
134
|
+
case "lte":
|
|
135
|
+
case "gt":
|
|
136
|
+
case "gte": {
|
|
137
|
+
const a = asNumber(actual);
|
|
138
|
+
const b = asNumber(operand);
|
|
139
|
+
if (a === void 0 || b === void 0) return false;
|
|
140
|
+
return condition.operator === "lt" ? a < b : condition.operator === "lte" ? a <= b : condition.operator === "gt" ? a > b : a >= b;
|
|
141
|
+
}
|
|
142
|
+
case "contains":
|
|
143
|
+
case "startsWith":
|
|
144
|
+
case "endsWith": {
|
|
145
|
+
const a = asString(actual);
|
|
146
|
+
const b = asString(operand);
|
|
147
|
+
if (a === void 0 || b === void 0) return false;
|
|
148
|
+
return condition.operator === "contains" ? a.includes(b) : condition.operator === "startsWith" ? a.startsWith(b) : a.endsWith(b);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
/** A rule matches when every one of its conditions matches (logical AND). */
|
|
153
|
+
const matchRule = (rule, ctx) => rule.conditions.every((c) => matchCondition(c, ctx));
|
|
154
|
+
/**
|
|
155
|
+
* Selects a rollout variant for a subject. Weights are normalised across all
|
|
156
|
+
* splits, and the subject's bucket (consistent for the flag/rule/subject) is
|
|
157
|
+
* mapped onto the cumulative weight ranges. Returns `MissingSubject` when no
|
|
158
|
+
* bucketing value is available (→ `TARGETING_KEY_MISSING`).
|
|
159
|
+
*/
|
|
160
|
+
const selectRolloutVariant = (flagKey, ruleId, splits, bucketBy, ctx) => {
|
|
161
|
+
const subject = bucketBy === void 0 ? targetingKeyOf(ctx) : asString(attributeOf(ctx, bucketBy));
|
|
162
|
+
if (subject === void 0) return { _tag: "MissingSubject" };
|
|
163
|
+
const total = splits.reduce((sum, s) => sum + s.weight, 0);
|
|
164
|
+
if (total <= 0) {
|
|
165
|
+
const first = splits[0];
|
|
166
|
+
if (first === void 0) return { _tag: "MissingSubject" };
|
|
167
|
+
return {
|
|
168
|
+
_tag: "Variant",
|
|
169
|
+
variant: first.variant,
|
|
170
|
+
bucket: 0
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const bucket = bucketFor(flagKey, ruleId, subject);
|
|
174
|
+
let cumulative = 0;
|
|
175
|
+
for (const split of splits) {
|
|
176
|
+
cumulative += split.weight / total * BUCKET_SCALE;
|
|
177
|
+
if (bucket < cumulative) return {
|
|
178
|
+
_tag: "Variant",
|
|
179
|
+
variant: split.variant,
|
|
180
|
+
bucket
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
_tag: "Variant",
|
|
185
|
+
variant: splits[splits.length - 1].variant,
|
|
186
|
+
bucket
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
const deepEqual = (a, b) => {
|
|
190
|
+
if (a === b) return true;
|
|
191
|
+
if (typeof a !== typeof b) return false;
|
|
192
|
+
if (a === null || b === null) return a === b;
|
|
193
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
194
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
195
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
196
|
+
}
|
|
197
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
198
|
+
const ak = Object.keys(a);
|
|
199
|
+
const bk = Object.keys(b);
|
|
200
|
+
if (ak.length !== bk.length) return false;
|
|
201
|
+
return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
};
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region ../engine/src/evaluate.ts
|
|
207
|
+
/**
|
|
208
|
+
* Looks up a variant's value and returns a successful resolution, or an
|
|
209
|
+
* abnormal `GENERAL` resolution if the variant is not declared (a
|
|
210
|
+
* misconfiguration that validation should have caught).
|
|
211
|
+
*/
|
|
212
|
+
const serveVariant = (flag, variant, reason) => {
|
|
213
|
+
if (!Object.prototype.hasOwnProperty.call(flag.variants, variant)) return Resolution.error({
|
|
214
|
+
value: null,
|
|
215
|
+
errorCode: "GENERAL",
|
|
216
|
+
errorMessage: `Flag '${flag.key}' references undeclared variant '${variant}'`,
|
|
217
|
+
flagMetadata: flag.metadata
|
|
218
|
+
});
|
|
219
|
+
return Resolution.ok({
|
|
220
|
+
value: flag.variants[variant],
|
|
221
|
+
variant,
|
|
222
|
+
reason,
|
|
223
|
+
flagMetadata: flag.metadata
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Evaluates a flag against an evaluation context, producing OpenFeature
|
|
228
|
+
* resolution details. This is the pure heart of the service.
|
|
229
|
+
*
|
|
230
|
+
* Order of resolution:
|
|
231
|
+
* 1. disabled flag → `offVariant` (or `defaultVariant`) with reason `DISABLED`
|
|
232
|
+
* 2. first matching targeting rule:
|
|
233
|
+
* - fixed variant → `TARGETING_MATCH`
|
|
234
|
+
* - rollout → `SPLIT` (or `TARGETING_KEY_MISSING` if no subject)
|
|
235
|
+
* 3. no rule matches → `defaultVariant` with reason `STATIC`
|
|
236
|
+
*/
|
|
237
|
+
const evaluateFlag = (flag, ctx) => {
|
|
238
|
+
if (!flag.enabled) return serveVariant(flag, flag.offVariant ?? flag.defaultVariant, "DISABLED");
|
|
239
|
+
for (const rule of flag.targeting) {
|
|
240
|
+
if (!matchRule(rule, ctx)) continue;
|
|
241
|
+
if (rule.serve.type === "variant") return serveVariant(flag, rule.serve.variant, "TARGETING_MATCH");
|
|
242
|
+
const outcome = selectRolloutVariant(flag.key, rule.id, rule.serve.splits, rule.serve.bucketBy, ctx);
|
|
243
|
+
if (outcome._tag === "MissingSubject") return Resolution.error({
|
|
244
|
+
value: flag.variants[flag.defaultVariant] ?? null,
|
|
245
|
+
variant: flag.defaultVariant,
|
|
246
|
+
errorCode: "TARGETING_KEY_MISSING",
|
|
247
|
+
errorMessage: `Flag '${flag.key}' rule '${rule.id}' requires a bucketing key but none was provided`,
|
|
248
|
+
flagMetadata: flag.metadata
|
|
249
|
+
});
|
|
250
|
+
return serveVariant(flag, outcome.variant, "SPLIT");
|
|
251
|
+
}
|
|
252
|
+
return serveVariant(flag, flag.defaultVariant, "STATIC");
|
|
253
|
+
};
|
|
13
254
|
//#endregion
|
|
14
255
|
//#region ../schema/src/audit.ts
|
|
15
256
|
/**
|
|
@@ -102,6 +343,15 @@ const CreateEnvironmentInput = Schema.Struct({
|
|
|
102
343
|
key: Schema.String.check(Schema.isMinLength(1)),
|
|
103
344
|
name: Schema.String.check(Schema.isMinLength(1))
|
|
104
345
|
});
|
|
346
|
+
Schema.Struct({
|
|
347
|
+
id: Schema.String,
|
|
348
|
+
tenantId: Schema.String,
|
|
349
|
+
/** The single environment this token is scoped to. */
|
|
350
|
+
environmentId: Schema.String,
|
|
351
|
+
/** The full token value — intentionally exposed; it is non-secret. */
|
|
352
|
+
token: Schema.String,
|
|
353
|
+
createdAt: Schema.String
|
|
354
|
+
});
|
|
105
355
|
/**
|
|
106
356
|
* Public view of an API key — safe to list. Never includes the secret token or
|
|
107
357
|
* its hash; only a non-secret prefix for human identification.
|
|
@@ -434,24 +684,59 @@ Schema.Struct({
|
|
|
434
684
|
tags: Schema.optionalKey(Tags)
|
|
435
685
|
});
|
|
436
686
|
//#endregion
|
|
687
|
+
//#region ../schema/src/sync.ts
|
|
688
|
+
/**
|
|
689
|
+
* Definition-sync wire schemas (ADR-0001 §4): the versioned full snapshot the
|
|
690
|
+
* sync surface serves to locally-evaluating SDKs, and the messages exchanged on
|
|
691
|
+
* the live sync stream. Like the OFREP schemas, these are shared by the server
|
|
692
|
+
* (producer) and `@toggld/node` (consumer) so the contract cannot drift.
|
|
693
|
+
*
|
|
694
|
+
* `version` is opaque and equality-compared (ADR-0005 phase 1); the ordered
|
|
695
|
+
* `config_version` lands in this same field later without a wire change.
|
|
696
|
+
*/
|
|
697
|
+
/** A versioned full snapshot of one environment's resolved flag set. */
|
|
698
|
+
const FlagSnapshot = Schema.Struct({
|
|
699
|
+
version: Schema.String,
|
|
700
|
+
flags: Schema.Array(Flag)
|
|
701
|
+
});
|
|
702
|
+
/** Server→client: the current snapshot (connect greeting and change pushes). */
|
|
703
|
+
const SyncSnapshotMessage = Schema.Struct({
|
|
704
|
+
type: Schema.Literal("snapshot"),
|
|
705
|
+
version: Schema.String,
|
|
706
|
+
flags: Schema.Array(Flag)
|
|
707
|
+
});
|
|
708
|
+
/**
|
|
709
|
+
* Every server→client sync-stream message. A single-member union today; the
|
|
710
|
+
* `type` discriminant is the extension point.
|
|
711
|
+
*/
|
|
712
|
+
const SyncServerMessage = Schema.Union([SyncSnapshotMessage]);
|
|
713
|
+
Schema.Struct({ type: Schema.Literal("resync") });
|
|
714
|
+
//#endregion
|
|
437
715
|
//#region src/internal/core.ts
|
|
438
716
|
/**
|
|
439
|
-
* The Effect core of the Toggld Node SDK
|
|
440
|
-
* server's OFREP endpoints.
|
|
717
|
+
* The Effect core of the Toggld Node SDK.
|
|
441
718
|
*
|
|
442
719
|
* This is the canonical implementation; `../index.ts` (the plain Promise API) is
|
|
443
|
-
* a thin facade that runs this under a `ManagedRuntime`.
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
720
|
+
* a thin facade that runs this under a `ManagedRuntime`. Two evaluation modes
|
|
721
|
+
* share one client surface (ADR-0001):
|
|
722
|
+
*
|
|
723
|
+
* - **Remote** (default): every call hits the flag API's OFREP endpoints, which
|
|
724
|
+
* evaluate the flag against the supplied context and return the resolved
|
|
725
|
+
* value (OpenFeature "remote evaluation").
|
|
726
|
+
* - **Local** (`mode: "local"`): the client backgrounds a **definition sync** —
|
|
727
|
+
* a versioned snapshot fetch (`GET /sync/v1/flags`) plus a live WebSocket
|
|
728
|
+
* stream (`/sync/v1/stream`) with polling fallback — and evaluates flags
|
|
729
|
+
* in-process with `@toggld/engine`. Zero server requests per evaluation; the
|
|
730
|
+
* engine is the same code the server runs, so resolutions are identical.
|
|
448
731
|
*
|
|
449
732
|
* OpenFeature semantics: evaluation never throws. Any failure — network error,
|
|
450
|
-
* timeout, unknown flag, a server-reported error,
|
|
451
|
-
* match the requested getter
|
|
452
|
-
*
|
|
733
|
+
* timeout, unknown flag, a server-reported error, a value whose type doesn't
|
|
734
|
+
* match the requested getter, or (local mode) no snapshot received yet —
|
|
735
|
+
* resolves to the caller's default value with the details carrying
|
|
736
|
+
* `reason: "ERROR"` and an `errorCode`.
|
|
453
737
|
*/
|
|
454
738
|
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
739
|
+
const DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
455
740
|
const isBoolean = (v) => typeof v === "boolean";
|
|
456
741
|
const isString = (v) => typeof v === "string";
|
|
457
742
|
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
@@ -483,6 +768,18 @@ const errorDetails = (key, value, errorCode, errorMessage, flagMetadata = {}) =>
|
|
|
483
768
|
errorMessage,
|
|
484
769
|
flagMetadata
|
|
485
770
|
});
|
|
771
|
+
/** Map an engine {@link Resolution} to evaluation details, OpenFeature-style:
|
|
772
|
+
* an abnormal resolution serves the **caller's** default, not the engine's
|
|
773
|
+
* internal fallback — exactly what the remote wire does on a 400. */
|
|
774
|
+
const resolutionDetails = (key, resolution, fallback) => resolution.errorCode !== void 0 ? errorDetails(key, fallback, resolution.errorCode, resolution.errorMessage ?? "", resolution.flagMetadata) : {
|
|
775
|
+
flagKey: key,
|
|
776
|
+
value: resolution.value,
|
|
777
|
+
variant: resolution.variant,
|
|
778
|
+
reason: resolution.reason,
|
|
779
|
+
errorCode: void 0,
|
|
780
|
+
errorMessage: void 0,
|
|
781
|
+
flagMetadata: resolution.flagMetadata
|
|
782
|
+
};
|
|
486
783
|
/** A readable message for an arbitrary thrown/failed value. */
|
|
487
784
|
const describe = (e) => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" ? e.message : String(e);
|
|
488
785
|
/** Map a transport/decoding failure to an OpenFeature error code. */
|
|
@@ -492,14 +789,7 @@ const codeFor = (e) => typeof e === "object" && e !== null && "_tag" in e && e._
|
|
|
492
789
|
* compose it into your runtime, then `yield* ToggldClient` to evaluate flags.
|
|
493
790
|
*/
|
|
494
791
|
var ToggldClient = class extends Context.Service()("@toggld/node/ToggldClient") {};
|
|
495
|
-
const
|
|
496
|
-
const base = yield* HttpClient.HttpClient;
|
|
497
|
-
const timeout = Duration.millis(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
498
|
-
const http = base.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(options.endpoint)), HttpClient.mapRequest(HttpClientRequest.bearerToken(options.sdkKey)));
|
|
499
|
-
const mergedContext = (context) => ({
|
|
500
|
-
...options.defaultContext,
|
|
501
|
-
...context
|
|
502
|
-
});
|
|
792
|
+
const makeRemote = (http, mergedContext, timeout) => {
|
|
503
793
|
const decode = HttpClientResponse.schemaBodyJson;
|
|
504
794
|
const evaluate = (key, defaultValue, context) => Effect.gen(function* () {
|
|
505
795
|
const request = HttpClientRequest.post(`/ofrep/v1/evaluate/flags/${encodeURIComponent(key)}`).pipe(HttpClientRequest.bodyJsonUnsafe({ context: mergedContext(context) }));
|
|
@@ -518,6 +808,108 @@ const make = (options) => Effect.gen(function* () {
|
|
|
518
808
|
orElse: (r) => Effect.fail(/* @__PURE__ */ new Error(`Unexpected response status ${r.status}`))
|
|
519
809
|
})).flags.map((entry) => "errorCode" in entry ? wireErrorDetails(null, entry) : okDetails(entry.key, entry));
|
|
520
810
|
}).pipe(Effect.timeout(timeout), Effect.catch(() => Effect.succeed([])));
|
|
811
|
+
return {
|
|
812
|
+
ready: Effect.void,
|
|
813
|
+
evaluate,
|
|
814
|
+
evaluateAll
|
|
815
|
+
};
|
|
816
|
+
};
|
|
817
|
+
/** The endpoint's `ws(s)://` form for the sync stream. */
|
|
818
|
+
const toStreamUrl = (endpoint) => {
|
|
819
|
+
const url = new URL(endpoint);
|
|
820
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
821
|
+
url.pathname = url.pathname.replace(/\/$/, "") + "/sync/v1/stream";
|
|
822
|
+
return url.toString();
|
|
823
|
+
};
|
|
824
|
+
const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function* () {
|
|
825
|
+
const transport = options.sync?.transport ?? "ws";
|
|
826
|
+
const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
827
|
+
let current;
|
|
828
|
+
let epoch = 0;
|
|
829
|
+
const firstSnapshot = yield* Deferred.make();
|
|
830
|
+
const adopt = (snapshot) => Effect.suspend(() => {
|
|
831
|
+
epoch++;
|
|
832
|
+
current = {
|
|
833
|
+
version: snapshot.version,
|
|
834
|
+
flags: snapshot.flags,
|
|
835
|
+
byKey: new Map(snapshot.flags.map((f) => [f.key, f]))
|
|
836
|
+
};
|
|
837
|
+
return Effect.asVoid(Deferred.succeed(firstSnapshot, void 0));
|
|
838
|
+
});
|
|
839
|
+
const fetchSnapshot = Effect.gen(function* () {
|
|
840
|
+
const response = yield* http.execute(HttpClientRequest.get("/sync/v1/flags"));
|
|
841
|
+
return yield* HttpClientResponse.matchStatus(response, {
|
|
842
|
+
"2xx": HttpClientResponse.schemaBodyJson(FlagSnapshot),
|
|
843
|
+
orElse: (r) => Effect.fail(/* @__PURE__ */ new Error(`Unexpected response status ${r.status}`))
|
|
844
|
+
});
|
|
845
|
+
}).pipe(Effect.timeout(timeout));
|
|
846
|
+
const pollOnce = Effect.gen(function* () {
|
|
847
|
+
const before = epoch;
|
|
848
|
+
const snapshot = yield* fetchSnapshot;
|
|
849
|
+
if (epoch !== before) return;
|
|
850
|
+
if (current !== void 0 && current.version === snapshot.version) return;
|
|
851
|
+
yield* adopt(snapshot);
|
|
852
|
+
});
|
|
853
|
+
const ticker = Effect.gen(function* () {
|
|
854
|
+
let delay = 250;
|
|
855
|
+
while (current === void 0) {
|
|
856
|
+
yield* pollOnce.pipe(Effect.ignore);
|
|
857
|
+
if (current !== void 0) break;
|
|
858
|
+
yield* Effect.sleep(Duration.millis(delay));
|
|
859
|
+
delay = Math.min(delay * 2, 5e3);
|
|
860
|
+
}
|
|
861
|
+
yield* Effect.forever(Effect.sleep(Duration.millis(pollIntervalMs)).pipe(Effect.andThen(Effect.ignore(pollOnce))));
|
|
862
|
+
});
|
|
863
|
+
const decodeMessage = Schema.decodeUnknownEffect(SyncServerMessage);
|
|
864
|
+
const handleMessage = (raw) => Effect.gen(function* () {
|
|
865
|
+
const message = yield* decodeMessage(JSON.parse(String(raw)));
|
|
866
|
+
if (current !== void 0 && current.version === message.version) return;
|
|
867
|
+
yield* adopt(message);
|
|
868
|
+
}).pipe(Effect.ignore);
|
|
869
|
+
const streamUrl = toStreamUrl(options.endpoint);
|
|
870
|
+
let consecutiveFailures = 0;
|
|
871
|
+
const streamLoop = Effect.gen(function* () {
|
|
872
|
+
const socket = yield* Socket.makeWebSocket(streamUrl, {
|
|
873
|
+
protocols: [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey}`],
|
|
874
|
+
openTimeout: timeout
|
|
875
|
+
});
|
|
876
|
+
yield* Effect.forever(Effect.gen(function* () {
|
|
877
|
+
yield* socket.runRaw(handleMessage, { onOpen: Effect.sync(() => {
|
|
878
|
+
consecutiveFailures = 0;
|
|
879
|
+
}) }).pipe(Effect.ignore);
|
|
880
|
+
consecutiveFailures++;
|
|
881
|
+
const base = Math.min(3e4, 1e3 * 2 ** Math.min(consecutiveFailures - 1, 5));
|
|
882
|
+
yield* Effect.sleep(Duration.millis(base * (.5 + Math.random() * .5)));
|
|
883
|
+
}));
|
|
884
|
+
});
|
|
885
|
+
yield* Effect.forkScoped(ticker);
|
|
886
|
+
if (transport === "ws") yield* Effect.forkScoped(streamLoop);
|
|
887
|
+
const evaluate = (key, defaultValue, context) => Effect.sync(() => {
|
|
888
|
+
if (current === void 0) return errorDetails(key, defaultValue, "PROVIDER_NOT_READY", "No flag snapshot synced yet");
|
|
889
|
+
const flag = current.byKey.get(key);
|
|
890
|
+
if (flag === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
|
|
891
|
+
return resolutionDetails(key, evaluateFlag(flag, mergedContext(context)), defaultValue);
|
|
892
|
+
});
|
|
893
|
+
const evaluateAll = (context) => Effect.sync(() => {
|
|
894
|
+
if (current === void 0) return [];
|
|
895
|
+
const ctx = mergedContext(context);
|
|
896
|
+
return current.flags.map((flag) => resolutionDetails(flag.key, evaluateFlag(flag, ctx), null));
|
|
897
|
+
});
|
|
898
|
+
return {
|
|
899
|
+
ready: Deferred.await(firstSnapshot),
|
|
900
|
+
evaluate,
|
|
901
|
+
evaluateAll
|
|
902
|
+
};
|
|
903
|
+
});
|
|
904
|
+
const make = (options) => Effect.gen(function* () {
|
|
905
|
+
const base = yield* HttpClient.HttpClient;
|
|
906
|
+
const timeout = Duration.millis(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
907
|
+
const http = base.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(options.endpoint)), HttpClient.mapRequest(HttpClientRequest.bearerToken(options.sdkKey)));
|
|
908
|
+
const mergedContext = (context) => ({
|
|
909
|
+
...options.defaultContext,
|
|
910
|
+
...context
|
|
911
|
+
});
|
|
912
|
+
const { evaluate, evaluateAll, ready } = options.mode === "local" ? yield* makeLocal(options, http, mergedContext, timeout) : makeRemote(http, mergedContext, timeout);
|
|
521
913
|
/** Resolve, then enforce the requested value type (else `TYPE_MISMATCH`). */
|
|
522
914
|
const typed = (key, defaultValue, context, type, guard) => Effect.map(evaluate(key, defaultValue, context), (d) => {
|
|
523
915
|
if (d.errorCode !== void 0) return d;
|
|
@@ -526,6 +918,7 @@ const make = (options) => Effect.gen(function* () {
|
|
|
526
918
|
});
|
|
527
919
|
const valueOf = (details) => Effect.map(details, (d) => d.value);
|
|
528
920
|
return {
|
|
921
|
+
ready,
|
|
529
922
|
evaluate,
|
|
530
923
|
evaluateAll,
|
|
531
924
|
getBooleanDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "boolean", isBoolean),
|
|
@@ -540,10 +933,14 @@ const make = (options) => Effect.gen(function* () {
|
|
|
540
933
|
});
|
|
541
934
|
/** The HttpClient layer, optionally backed by a caller-supplied `fetch`. */
|
|
542
935
|
const httpLayer = (fetchImpl) => fetchImpl ? FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImpl))) : FetchHttpClient.layer;
|
|
936
|
+
/** The WebSocket constructor for the sync stream, optionally caller-supplied. */
|
|
937
|
+
const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructor, (url, protocols) => new ctor(url, protocols)) : Socket.layerWebSocketConstructorGlobal;
|
|
543
938
|
/**
|
|
544
939
|
* A self-contained `Layer` providing {@link ToggldClient} — it wires its own
|
|
545
|
-
* HTTP
|
|
940
|
+
* HTTP and WebSocket backends, so Effect users need only provide this one
|
|
941
|
+
* layer. In local mode the background sync fibers live in the layer's scope:
|
|
942
|
+
* closing it (e.g. `ManagedRuntime.dispose`) stops sync and closes the stream.
|
|
546
943
|
*/
|
|
547
|
-
const layer = (options) => Layer.effect(ToggldClient, make(options)).pipe(Layer.provide(httpLayer(options.fetch)));
|
|
944
|
+
const layer = (options) => Layer.effect(ToggldClient, make(options)).pipe(Layer.provide(httpLayer(options.fetch)), Layer.provide(webSocketLayer(options.sync?.webSocketConstructor)));
|
|
548
945
|
//#endregion
|
|
549
946
|
export { layer as n, ToggldClient as t };
|
package/dist/effect.d.mts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as ErrorCode, c as FlagValue, i as ToggldClientOptions, l as Reason, n as EvaluationMode, o as EvaluationContext, r as SyncOptions, s as FlagMetadata, t as EvaluationDetails } from "./types-DItF1mIU.mjs";
|
|
2
2
|
import { Context, Effect, Layer } from "effect";
|
|
3
3
|
|
|
4
4
|
//#region src/internal/core.d.ts
|
|
5
5
|
/** The evaluation surface of a Toggld client. Every method is non-failing. */
|
|
6
6
|
interface ToggldClientShape {
|
|
7
|
+
/**
|
|
8
|
+
* Resolves once the client is ready to evaluate: immediately in remote mode;
|
|
9
|
+
* on the first synced snapshot in local mode. Until then local evaluations
|
|
10
|
+
* resolve to the caller's default with `PROVIDER_NOT_READY`.
|
|
11
|
+
*/
|
|
12
|
+
readonly ready: Effect.Effect<void>;
|
|
7
13
|
/** Resolve a flag, returning full details with an untyped (JSON) value. */
|
|
8
14
|
readonly evaluate: (key: string, defaultValue: FlagValue, context?: EvaluationContext) => Effect.Effect<EvaluationDetails<FlagValue>>;
|
|
9
|
-
/** Resolve every flag for the environment
|
|
15
|
+
/** Resolve every flag for the environment (one request in remote mode; in-process in local mode). */
|
|
10
16
|
readonly evaluateAll: (context?: EvaluationContext) => Effect.Effect<ReadonlyArray<EvaluationDetails<FlagValue>>>;
|
|
11
17
|
readonly getBooleanValue: (key: string, defaultValue: boolean, context?: EvaluationContext) => Effect.Effect<boolean>;
|
|
12
18
|
readonly getBooleanDetails: (key: string, defaultValue: boolean, context?: EvaluationContext) => Effect.Effect<EvaluationDetails<boolean>>;
|
|
@@ -25,8 +31,10 @@ declare const ToggldClient_base: Context.ServiceClass<ToggldClient, "@toggld/nod
|
|
|
25
31
|
declare class ToggldClient extends ToggldClient_base {}
|
|
26
32
|
/**
|
|
27
33
|
* A self-contained `Layer` providing {@link ToggldClient} — it wires its own
|
|
28
|
-
* HTTP
|
|
34
|
+
* HTTP and WebSocket backends, so Effect users need only provide this one
|
|
35
|
+
* layer. In local mode the background sync fibers live in the layer's scope:
|
|
36
|
+
* closing it (e.g. `ManagedRuntime.dispose`) stops sync and closes the stream.
|
|
29
37
|
*/
|
|
30
38
|
declare const layer: (options: ToggldClientOptions) => Layer.Layer<ToggldClient>;
|
|
31
39
|
//#endregion
|
|
32
|
-
export { type ErrorCode, type EvaluationContext, type EvaluationDetails, type FlagMetadata, type FlagValue, type Reason, ToggldClient, type ToggldClientOptions, type ToggldClientShape, layer };
|
|
40
|
+
export { type ErrorCode, type EvaluationContext, type EvaluationDetails, type EvaluationMode, type FlagMetadata, type FlagValue, type Reason, type SyncOptions, ToggldClient, type ToggldClientOptions, type ToggldClientShape, layer };
|
package/dist/effect.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as layer, t as ToggldClient } from "./core-
|
|
1
|
+
import { n as layer, t as ToggldClient } from "./core-Bx0PVoEm.mjs";
|
|
2
2
|
export { ToggldClient, layer };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as ErrorCode, c as FlagValue, i as ToggldClientOptions, l as Reason, n as EvaluationMode, o as EvaluationContext, r as SyncOptions, s as FlagMetadata, t as EvaluationDetails } from "./types-DItF1mIU.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/index.d.ts
|
|
4
4
|
/**
|
|
@@ -9,8 +9,19 @@ import { a as FlagMetadata, i as EvaluationContext, n as ToggldClientOptions, o
|
|
|
9
9
|
declare class ToggldClient {
|
|
10
10
|
#private;
|
|
11
11
|
private constructor();
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Create a client. Resolves once the underlying runtime is ready — and, in
|
|
14
|
+
* local mode, once the first flag snapshot has synced (or `readyTimeoutMs`,
|
|
15
|
+
* default 5 s, has elapsed; the client then serves caller defaults with
|
|
16
|
+
* `PROVIDER_NOT_READY` while sync keeps retrying in the background).
|
|
17
|
+
*/
|
|
13
18
|
static create(options: ToggldClientOptions): Promise<ToggldClient>;
|
|
19
|
+
/**
|
|
20
|
+
* Resolves once the client has flag data to evaluate with (immediately in
|
|
21
|
+
* remote mode). Useful if `create` resolved on the ready timeout and you
|
|
22
|
+
* want to await sync explicitly.
|
|
23
|
+
*/
|
|
24
|
+
ready(): Promise<void>;
|
|
14
25
|
/** Resolve a flag, returning full details with an untyped (JSON) value. */
|
|
15
26
|
evaluate(key: string, defaultValue: FlagValue, context?: EvaluationContext): Promise<EvaluationDetails<FlagValue>>;
|
|
16
27
|
/** Resolve every flag for the environment in one request (OFREP bulk). */
|
|
@@ -27,4 +38,4 @@ declare class ToggldClient {
|
|
|
27
38
|
close(): Promise<void>;
|
|
28
39
|
}
|
|
29
40
|
//#endregion
|
|
30
|
-
export { type ErrorCode, type EvaluationContext, type EvaluationDetails, type FlagMetadata, type FlagValue, type Reason, ToggldClient, type ToggldClientOptions };
|
|
41
|
+
export { type ErrorCode, type EvaluationContext, type EvaluationDetails, type EvaluationMode, type FlagMetadata, type FlagValue, type Reason, type SyncOptions, ToggldClient, type ToggldClientOptions };
|
package/dist/index.mjs
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
|
-
import { n as layer, t as ToggldClient$1 } from "./core-
|
|
2
|
-
import { ManagedRuntime } from "effect";
|
|
1
|
+
import { n as layer, t as ToggldClient$1 } from "./core-Bx0PVoEm.mjs";
|
|
2
|
+
import { Duration, Effect, ManagedRuntime } from "effect";
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
/**
|
|
5
5
|
* `@toggld/node` — the default entry: a plain Promise API.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* under a `ManagedRuntime`.
|
|
7
|
+
* Flag evaluation with **no Effect in any signature**. Internally it runs the
|
|
8
|
+
* Effect implementation from `./effect` under a `ManagedRuntime`.
|
|
10
9
|
*
|
|
11
10
|
* ```ts
|
|
12
11
|
* import { ToggldClient } from "@toggld/node"
|
|
13
12
|
*
|
|
14
|
-
*
|
|
13
|
+
* // Local evaluation (recommended for long-lived servers): flag definitions
|
|
14
|
+
* // sync in the background and every evaluation is in-process — microseconds,
|
|
15
|
+
* // zero server requests.
|
|
16
|
+
* const client = await ToggldClient.create({ sdkKey, endpoint, mode: "local" })
|
|
15
17
|
* const on = await client.getBooleanValue("new-checkout", false, { targetingKey: "user-123" })
|
|
16
18
|
* await client.close()
|
|
17
19
|
* ```
|
|
18
20
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
+
* Omitting `mode` keeps the default **remote** evaluation (each call asks the
|
|
22
|
+
* flag API over OFREP). Evaluation never throws: any failure resolves to the
|
|
23
|
+
* supplied default value, with the reason/error available via the `*Details`
|
|
24
|
+
* methods.
|
|
21
25
|
*/
|
|
22
26
|
/**
|
|
23
27
|
* A Toggld client backed by a private Effect runtime. Create one with {@link
|
|
@@ -31,10 +35,28 @@ var ToggldClient = class ToggldClient {
|
|
|
31
35
|
this.#runtime = runtime;
|
|
32
36
|
this.#client = client;
|
|
33
37
|
}
|
|
34
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Create a client. Resolves once the underlying runtime is ready — and, in
|
|
40
|
+
* local mode, once the first flag snapshot has synced (or `readyTimeoutMs`,
|
|
41
|
+
* default 5 s, has elapsed; the client then serves caller defaults with
|
|
42
|
+
* `PROVIDER_NOT_READY` while sync keeps retrying in the background).
|
|
43
|
+
*/
|
|
35
44
|
static async create(options) {
|
|
36
45
|
const runtime = ManagedRuntime.make(layer(options));
|
|
37
|
-
|
|
46
|
+
const client = await runtime.runPromise(ToggldClient$1);
|
|
47
|
+
if (options.mode === "local") {
|
|
48
|
+
const readyTimeoutMs = options.sync?.readyTimeoutMs ?? 5e3;
|
|
49
|
+
await runtime.runPromise(client.ready.pipe(Effect.timeout(Duration.millis(readyTimeoutMs)), Effect.ignore));
|
|
50
|
+
}
|
|
51
|
+
return new ToggldClient(runtime, client);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolves once the client has flag data to evaluate with (immediately in
|
|
55
|
+
* remote mode). Useful if `create` resolved on the ready timeout and you
|
|
56
|
+
* want to await sync explicitly.
|
|
57
|
+
*/
|
|
58
|
+
ready() {
|
|
59
|
+
return this.#runtime.runPromise(this.#client.ready);
|
|
38
60
|
}
|
|
39
61
|
/** Resolve a flag, returning full details with an untyped (JSON) value. */
|
|
40
62
|
evaluate(key, defaultValue, context) {
|
|
@@ -34,20 +34,72 @@ type Reason = "STATIC" | "DEFAULT" | "TARGETING_MATCH" | "SPLIT" | "CACHED" | "D
|
|
|
34
34
|
type ErrorCode = "PROVIDER_NOT_READY" | "PROVIDER_FATAL" | "FLAG_NOT_FOUND" | "PARSE_ERROR" | "TYPE_MISMATCH" | "TARGETING_KEY_MISSING" | "INVALID_CONTEXT" | "GENERAL";
|
|
35
35
|
//#endregion
|
|
36
36
|
//#region src/internal/types.d.ts
|
|
37
|
+
/**
|
|
38
|
+
* How the client resolves flags.
|
|
39
|
+
*
|
|
40
|
+
* - `"remote"` — every evaluation is an HTTP request to the flag API's OFREP
|
|
41
|
+
* endpoints; the server evaluates and returns the value.
|
|
42
|
+
* - `"local"` — the client backgrounds a **definition sync** (a snapshot fetch
|
|
43
|
+
* plus a live WebSocket stream with polling fallback) and evaluates flags
|
|
44
|
+
* **in-process** with the bundled engine: zero server requests per
|
|
45
|
+
* evaluation, microsecond latency, identical resolutions to the server. The
|
|
46
|
+
* mode for long-lived servers.
|
|
47
|
+
*/
|
|
48
|
+
type EvaluationMode = "remote" | "local";
|
|
49
|
+
/** Tuning for the background definition sync (`mode: "local"`). */
|
|
50
|
+
interface SyncOptions {
|
|
51
|
+
/**
|
|
52
|
+
* How updates reach the client.
|
|
53
|
+
*
|
|
54
|
+
* - `"ws"` (default) — a live WebSocket stream (`/sync/v1/stream`) pushes a
|
|
55
|
+
* fresh snapshot on every change, with the snapshot poll below as a
|
|
56
|
+
* safety net (it also serves runtimes where the stream is unavailable —
|
|
57
|
+
* the client degrades to polling automatically).
|
|
58
|
+
* - `"poll"` — no stream; only the periodic snapshot poll.
|
|
59
|
+
*/
|
|
60
|
+
readonly transport?: "ws" | "poll";
|
|
61
|
+
/**
|
|
62
|
+
* Interval between snapshot polls in milliseconds. Default `30000`. Until
|
|
63
|
+
* the first snapshot arrives, the client retries faster (exponential from
|
|
64
|
+
* 250 ms) so cold starts aren't gated on this interval.
|
|
65
|
+
*/
|
|
66
|
+
readonly pollIntervalMs?: number;
|
|
67
|
+
/**
|
|
68
|
+
* How long `ToggldClient.create` waits for the first snapshot before
|
|
69
|
+
* resolving anyway, in milliseconds. Default `5000`. A client that isn't
|
|
70
|
+
* ready evaluates to caller defaults (`PROVIDER_NOT_READY`) while sync keeps
|
|
71
|
+
* retrying in the background.
|
|
72
|
+
*/
|
|
73
|
+
readonly readyTimeoutMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Custom `WebSocket` constructor for the stream transport. Defaults to the
|
|
76
|
+
* global `WebSocket` (Node ≥ 22). Useful for tests or proxies.
|
|
77
|
+
*/
|
|
78
|
+
readonly webSocketConstructor?: typeof globalThis.WebSocket;
|
|
79
|
+
}
|
|
37
80
|
/** Configuration for a {@link https://toggld.io Toggld} client. */
|
|
38
81
|
interface ToggldClientOptions {
|
|
39
82
|
/**
|
|
40
83
|
* An **evaluation** (or management) API key, sent as `Authorization: Bearer
|
|
41
84
|
* <sdkKey>`. The key is scoped to one tenant + environment server-side, so a
|
|
42
|
-
* client always evaluates against exactly that environment.
|
|
85
|
+
* client always evaluates against exactly that environment. Local evaluation
|
|
86
|
+
* requires a secret key (`ffk_…`) — public browser tokens cannot read flag
|
|
87
|
+
* definitions.
|
|
43
88
|
*/
|
|
44
89
|
readonly sdkKey: string;
|
|
45
90
|
/**
|
|
46
91
|
* Base URL of the flag API, e.g. `https://toggld-api.example.workers.dev` or
|
|
47
92
|
* `http://localhost:3000` in local development. No trailing path — the client
|
|
48
|
-
* appends the OFREP routes (`/ofrep/v1/...`).
|
|
93
|
+
* appends the OFREP and sync routes (`/ofrep/v1/...`, `/sync/v1/...`).
|
|
49
94
|
*/
|
|
50
95
|
readonly endpoint: string;
|
|
96
|
+
/**
|
|
97
|
+
* Evaluation mode. Defaults to `"remote"`; prefer `"local"` on long-lived
|
|
98
|
+
* servers. See {@link EvaluationMode}.
|
|
99
|
+
*/
|
|
100
|
+
readonly mode?: EvaluationMode;
|
|
101
|
+
/** Background sync tuning for `mode: "local"`. Ignored in remote mode. */
|
|
102
|
+
readonly sync?: SyncOptions;
|
|
51
103
|
/**
|
|
52
104
|
* Attributes merged into the evaluation context of **every** request, under
|
|
53
105
|
* any per-call context (per-call attributes win on key collision). Handy for
|
|
@@ -82,4 +134,4 @@ interface EvaluationDetails<T extends FlagValue> {
|
|
|
82
134
|
readonly flagMetadata: FlagMetadata;
|
|
83
135
|
}
|
|
84
136
|
//#endregion
|
|
85
|
-
export {
|
|
137
|
+
export { ErrorCode as a, FlagValue as c, ToggldClientOptions as i, Reason as l, EvaluationMode as n, EvaluationContext as o, SyncOptions as r, FlagMetadata as s, EvaluationDetails as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toggld/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Toggld SDK for Node.js — local flag evaluation with a background definition sync. Plain Promise API by default; native Effect API via @toggld/node/effect.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://toggld.io",
|
|
@@ -41,8 +41,11 @@
|
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"tsdown": "^0.22.2",
|
|
44
|
+
"typescript": "5.8.3",
|
|
44
45
|
"unrun": "^0.3.1",
|
|
46
|
+
"@toggld/engine": "0.1.0",
|
|
45
47
|
"@toggld/schema": "0.1.0",
|
|
48
|
+
"@toggld/server": "0.1.0",
|
|
46
49
|
"@toggld/spec": "0.1.0"
|
|
47
50
|
},
|
|
48
51
|
"scripts": {
|