@toggld/node 0.1.0 → 0.3.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 +77 -5
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +237 -0
- package/dist/core-DBVGNtur.mjs +644 -0
- package/dist/effect.d.mts +12 -4
- package/dist/effect.mjs +1 -1
- package/dist/index.d.mts +14 -3
- package/dist/index.mjs +30 -10
- package/dist/{core-zMjaOru1.mjs → src-CCPvsrli.mjs} +142 -120
- package/dist/types-CPtRV5-5.d.mts +163 -0
- package/package.json +7 -1
- package/dist/types-RmWNnA7-.d.mts +0 -85
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { a as OfrepSuccess, i as OfrepFailure, n as SyncServerMessage, r as OfrepBulkResponse, t as FlagSnapshot } from "./src-CCPvsrli.mjs";
|
|
2
|
+
import { Context, Deferred, Duration, Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { readFile, watch } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname } from "node:path";
|
|
5
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
|
6
|
+
import { Socket } from "effect/unstable/socket";
|
|
7
|
+
//#region ../spec/src/index.ts
|
|
8
|
+
/** True when `value` is assignable to a flag declared as `type`. */
|
|
9
|
+
const valueMatchesType = (value, type) => {
|
|
10
|
+
switch (type) {
|
|
11
|
+
case "boolean": return typeof value === "boolean";
|
|
12
|
+
case "string": return typeof value === "string";
|
|
13
|
+
case "number": return typeof value === "number" && Number.isFinite(value);
|
|
14
|
+
case "object": return typeof value === "object" && value !== null;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The sync stream's WebSocket subprotocol name. Offered first by clients (the
|
|
19
|
+
* server echoes the first offer back, completing the RFC 6455 negotiation).
|
|
20
|
+
*/
|
|
21
|
+
const SYNC_PROTOCOL = "toggld.sync.v1";
|
|
22
|
+
/**
|
|
23
|
+
* Subprotocol entry carrying the bearer credential on a sync WebSocket
|
|
24
|
+
* upgrade: `toggld.bearer.<token>`. The standard `WebSocket` constructor
|
|
25
|
+
* cannot set an `Authorization` header, so the token rides the
|
|
26
|
+
* `Sec-WebSocket-Protocol` offer instead (a recognized pattern — it stays out
|
|
27
|
+
* of URLs and access logs). Servers accept either this or a real
|
|
28
|
+
* `Authorization: Bearer` header.
|
|
29
|
+
*/
|
|
30
|
+
const SYNC_BEARER_PROTOCOL_PREFIX = "toggld.bearer.";
|
|
31
|
+
const TARGETING_KEY = "targetingKey";
|
|
32
|
+
/** Reads the targeting key from a context, if present and a non-empty string. */
|
|
33
|
+
const targetingKeyOf = (ctx) => {
|
|
34
|
+
const v = ctx[TARGETING_KEY];
|
|
35
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
36
|
+
};
|
|
37
|
+
/** Reads an arbitrary attribute, treating `targetingKey` like any other field. */
|
|
38
|
+
const attributeOf = (ctx, attribute) => Object.prototype.hasOwnProperty.call(ctx, attribute) ? ctx[attribute] : void 0;
|
|
39
|
+
const Resolution = {
|
|
40
|
+
/** A successful resolution. */
|
|
41
|
+
ok: (args) => ({
|
|
42
|
+
value: args.value,
|
|
43
|
+
variant: args.variant,
|
|
44
|
+
reason: args.reason,
|
|
45
|
+
errorCode: void 0,
|
|
46
|
+
errorMessage: void 0,
|
|
47
|
+
flagMetadata: args.flagMetadata ?? {}
|
|
48
|
+
}),
|
|
49
|
+
/** An abnormal resolution carrying an error code and a fallback value. */
|
|
50
|
+
error: (args) => ({
|
|
51
|
+
value: args.value,
|
|
52
|
+
variant: args.variant,
|
|
53
|
+
reason: "ERROR",
|
|
54
|
+
errorCode: args.errorCode,
|
|
55
|
+
errorMessage: args.errorMessage,
|
|
56
|
+
flagMetadata: args.flagMetadata ?? {}
|
|
57
|
+
})
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region ../engine/src/hash.ts
|
|
61
|
+
/**
|
|
62
|
+
* MurmurHash3 (x86, 32-bit). A fast, well-distributed, non-cryptographic hash.
|
|
63
|
+
* Used to deterministically bucket a subject into a percentage rollout slice so
|
|
64
|
+
* that a given subject always lands in the same slice for a given flag/rule.
|
|
65
|
+
*/
|
|
66
|
+
const murmur3 = (key, seed = 0) => {
|
|
67
|
+
const data = new TextEncoder().encode(key);
|
|
68
|
+
const len = data.length;
|
|
69
|
+
const c1 = 3432918353;
|
|
70
|
+
const c2 = 461845907;
|
|
71
|
+
let h1 = seed >>> 0;
|
|
72
|
+
const nblocks = len >>> 2;
|
|
73
|
+
const mul32 = (a, b) => {
|
|
74
|
+
const aHi = a >>> 16 & 65535;
|
|
75
|
+
return ((a & 65535) * b >>> 0) + ((aHi * b & 65535) << 16) >>> 0;
|
|
76
|
+
};
|
|
77
|
+
const rotl32 = (x, r) => (x << r | x >>> 32 - r) >>> 0;
|
|
78
|
+
for (let i = 0; i < nblocks; i++) {
|
|
79
|
+
const o = i * 4;
|
|
80
|
+
let k1 = (data[o] | data[o + 1] << 8 | data[o + 2] << 16 | data[o + 3] << 24) >>> 0;
|
|
81
|
+
k1 = mul32(k1, c1);
|
|
82
|
+
k1 = rotl32(k1, 15);
|
|
83
|
+
k1 = mul32(k1, c2);
|
|
84
|
+
h1 = (h1 ^ k1) >>> 0;
|
|
85
|
+
h1 = rotl32(h1, 13);
|
|
86
|
+
h1 = mul32(h1, 5) + 3864292196 >>> 0;
|
|
87
|
+
}
|
|
88
|
+
let k1 = 0;
|
|
89
|
+
const tail = nblocks * 4;
|
|
90
|
+
const rem = len & 3;
|
|
91
|
+
if (rem === 3) k1 ^= data[tail + 2] << 16;
|
|
92
|
+
if (rem >= 2) k1 ^= data[tail + 1] << 8;
|
|
93
|
+
if (rem >= 1) {
|
|
94
|
+
k1 ^= data[tail];
|
|
95
|
+
k1 = mul32(k1, c1);
|
|
96
|
+
k1 = rotl32(k1, 15);
|
|
97
|
+
k1 = mul32(k1, c2);
|
|
98
|
+
h1 = (h1 ^ k1) >>> 0;
|
|
99
|
+
}
|
|
100
|
+
h1 = (h1 ^ len) >>> 0;
|
|
101
|
+
h1 = (h1 ^ h1 >>> 16) >>> 0;
|
|
102
|
+
h1 = mul32(h1, 2246822507);
|
|
103
|
+
h1 = (h1 ^ h1 >>> 13) >>> 0;
|
|
104
|
+
h1 = mul32(h1, 3266489909);
|
|
105
|
+
h1 = (h1 ^ h1 >>> 16) >>> 0;
|
|
106
|
+
return h1 >>> 0;
|
|
107
|
+
};
|
|
108
|
+
/** Resolution of a bucket: a subject maps to an integer in `[0, BUCKET_SCALE)`. */
|
|
109
|
+
const BUCKET_SCALE = 1e4;
|
|
110
|
+
/**
|
|
111
|
+
* Deterministically maps a subject to a bucket in `[0, BUCKET_SCALE)`. The hash
|
|
112
|
+
* input combines the flag key, rule id, and subject so that the same subject
|
|
113
|
+
* gets independent buckets across different flags/rules (no correlation).
|
|
114
|
+
*/
|
|
115
|
+
const bucketFor = (flagKey, ruleId, subject) => {
|
|
116
|
+
return murmur3(`${flagKey}:${ruleId}:${subject}`) % BUCKET_SCALE;
|
|
117
|
+
};
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region ../engine/src/targeting.ts
|
|
120
|
+
const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
121
|
+
const asString = (v) => typeof v === "string" ? v : void 0;
|
|
122
|
+
/** Evaluates a single condition against the context. */
|
|
123
|
+
const matchCondition = (condition, ctx) => {
|
|
124
|
+
const actual = attributeOf(ctx, condition.attribute);
|
|
125
|
+
const operand = condition.value;
|
|
126
|
+
switch (condition.operator) {
|
|
127
|
+
case "exists": return actual !== void 0 && actual !== null;
|
|
128
|
+
case "notExists": return actual === void 0 || actual === null;
|
|
129
|
+
}
|
|
130
|
+
if (actual === void 0 || actual === null) return false;
|
|
131
|
+
switch (condition.operator) {
|
|
132
|
+
case "eq": return deepEqual(actual, operand ?? null);
|
|
133
|
+
case "ne": return !deepEqual(actual, operand ?? null);
|
|
134
|
+
case "in": return Array.isArray(operand) && operand.some((o) => deepEqual(actual, o));
|
|
135
|
+
case "notIn": return !Array.isArray(operand) || !operand.some((o) => deepEqual(actual, o));
|
|
136
|
+
case "lt":
|
|
137
|
+
case "lte":
|
|
138
|
+
case "gt":
|
|
139
|
+
case "gte": {
|
|
140
|
+
const a = asNumber(actual);
|
|
141
|
+
const b = asNumber(operand);
|
|
142
|
+
if (a === void 0 || b === void 0) return false;
|
|
143
|
+
return condition.operator === "lt" ? a < b : condition.operator === "lte" ? a <= b : condition.operator === "gt" ? a > b : a >= b;
|
|
144
|
+
}
|
|
145
|
+
case "contains":
|
|
146
|
+
case "startsWith":
|
|
147
|
+
case "endsWith": {
|
|
148
|
+
const a = asString(actual);
|
|
149
|
+
const b = asString(operand);
|
|
150
|
+
if (a === void 0 || b === void 0) return false;
|
|
151
|
+
return condition.operator === "contains" ? a.includes(b) : condition.operator === "startsWith" ? a.startsWith(b) : a.endsWith(b);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* A rule matches when every one of its conditions matches **and** every
|
|
157
|
+
* referenced segment resolves (member XOR `negate`) through `isMemberOf`
|
|
158
|
+
* (logical AND throughout). A rule with no segment references behaves exactly as
|
|
159
|
+
* conditions-only. `isMemberOf` is consulted only when the rule has segments;
|
|
160
|
+
* when absent, any segment reference resolves `false` (closed).
|
|
161
|
+
*/
|
|
162
|
+
const matchRule = (rule, ctx, isMemberOf) => {
|
|
163
|
+
if (!rule.conditions.every((c) => matchCondition(c, ctx))) return false;
|
|
164
|
+
if (rule.segments === void 0 || rule.segments.length === 0) return true;
|
|
165
|
+
return rule.segments.every((ref) => {
|
|
166
|
+
const member = isMemberOf?.(ref.segment) ?? false;
|
|
167
|
+
return ref.negate ? !member : member;
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Resolves segment membership for a subject from a synced segment *definition*
|
|
172
|
+
* (ADR-0006) — the local path for rule-based and `inline`-list segments. Pure
|
|
173
|
+
* and isomorphic: the server and the Node SDK build their `isMemberOf` over
|
|
174
|
+
* this. Membership is the union of a rule match (OR of rules, each an AND of
|
|
175
|
+
* conditions) and presence in the inline member list (keyed by `keyAttribute`).
|
|
176
|
+
* `remote` segments carry no `members` here and must be resolved out of band.
|
|
177
|
+
*/
|
|
178
|
+
const matchSegment = (segment, ctx) => {
|
|
179
|
+
if (segment.rules.some((r) => r.conditions.every((c) => matchCondition(c, ctx)))) return true;
|
|
180
|
+
if (segment.members !== void 0 && segment.members.length > 0) {
|
|
181
|
+
const subject = attributeOf(ctx, segment.keyAttribute);
|
|
182
|
+
if (typeof subject === "string" && segment.members.includes(subject)) return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Selects a rollout variant for a subject. Weights are normalised across all
|
|
188
|
+
* splits, and the subject's bucket (consistent for the flag/rule/subject) is
|
|
189
|
+
* mapped onto the cumulative weight ranges. Returns `MissingSubject` when no
|
|
190
|
+
* bucketing value is available (→ `TARGETING_KEY_MISSING`).
|
|
191
|
+
*/
|
|
192
|
+
const selectRolloutVariant = (flagKey, ruleId, splits, bucketBy, ctx) => {
|
|
193
|
+
const subject = bucketBy === void 0 ? targetingKeyOf(ctx) : asString(attributeOf(ctx, bucketBy));
|
|
194
|
+
if (subject === void 0) return { _tag: "MissingSubject" };
|
|
195
|
+
const total = splits.reduce((sum, s) => sum + s.weight, 0);
|
|
196
|
+
if (total <= 0) {
|
|
197
|
+
const first = splits[0];
|
|
198
|
+
if (first === void 0) return { _tag: "MissingSubject" };
|
|
199
|
+
return {
|
|
200
|
+
_tag: "Variant",
|
|
201
|
+
variant: first.variant,
|
|
202
|
+
bucket: 0
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const bucket = bucketFor(flagKey, ruleId, subject);
|
|
206
|
+
let cumulative = 0;
|
|
207
|
+
for (const split of splits) {
|
|
208
|
+
cumulative += split.weight / total * BUCKET_SCALE;
|
|
209
|
+
if (bucket < cumulative) return {
|
|
210
|
+
_tag: "Variant",
|
|
211
|
+
variant: split.variant,
|
|
212
|
+
bucket
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
_tag: "Variant",
|
|
217
|
+
variant: splits[splits.length - 1].variant,
|
|
218
|
+
bucket
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
const deepEqual = (a, b) => {
|
|
222
|
+
if (a === b) return true;
|
|
223
|
+
if (typeof a !== typeof b) return false;
|
|
224
|
+
if (a === null || b === null) return a === b;
|
|
225
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
226
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
227
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
228
|
+
}
|
|
229
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
230
|
+
const ak = Object.keys(a);
|
|
231
|
+
const bk = Object.keys(b);
|
|
232
|
+
if (ak.length !== bk.length) return false;
|
|
233
|
+
return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
};
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region ../engine/src/evaluate.ts
|
|
239
|
+
/**
|
|
240
|
+
* Looks up a variant's value and returns a successful resolution, or an
|
|
241
|
+
* abnormal `GENERAL` resolution if the variant is not declared (a
|
|
242
|
+
* misconfiguration that validation should have caught).
|
|
243
|
+
*/
|
|
244
|
+
const serveVariant = (flag, variant, reason) => {
|
|
245
|
+
if (!Object.prototype.hasOwnProperty.call(flag.variants, variant)) return Resolution.error({
|
|
246
|
+
value: null,
|
|
247
|
+
errorCode: "GENERAL",
|
|
248
|
+
errorMessage: `Flag '${flag.key}' references undeclared variant '${variant}'`,
|
|
249
|
+
flagMetadata: flag.metadata
|
|
250
|
+
});
|
|
251
|
+
return Resolution.ok({
|
|
252
|
+
value: flag.variants[variant],
|
|
253
|
+
variant,
|
|
254
|
+
reason,
|
|
255
|
+
flagMetadata: flag.metadata
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Evaluates a flag against an evaluation context, producing OpenFeature
|
|
260
|
+
* resolution details. This is the pure heart of the service.
|
|
261
|
+
*
|
|
262
|
+
* Order of resolution:
|
|
263
|
+
* 1. disabled flag → `offVariant` (or `defaultVariant`) with reason `DISABLED`
|
|
264
|
+
* 2. first matching targeting rule:
|
|
265
|
+
* - fixed variant → `TARGETING_MATCH`
|
|
266
|
+
* - rollout → `SPLIT` (or `TARGETING_KEY_MISSING` if no subject)
|
|
267
|
+
* 3. no rule matches → `defaultVariant` with reason `STATIC`
|
|
268
|
+
*
|
|
269
|
+
* `isMemberOf` resolves any {@link SegmentRef} a rule references (ADR-0006). It
|
|
270
|
+
* is optional: flags with no segment references never call it, and when it is
|
|
271
|
+
* omitted a segment reference resolves `false` (closed).
|
|
272
|
+
*/
|
|
273
|
+
const evaluateFlag = (flag, ctx, isMemberOf) => {
|
|
274
|
+
if (!flag.enabled) return serveVariant(flag, flag.offVariant ?? flag.defaultVariant, "DISABLED");
|
|
275
|
+
for (const rule of flag.targeting) {
|
|
276
|
+
if (!matchRule(rule, ctx, isMemberOf)) continue;
|
|
277
|
+
if (rule.serve.type === "variant") return serveVariant(flag, rule.serve.variant, "TARGETING_MATCH");
|
|
278
|
+
const outcome = selectRolloutVariant(flag.key, rule.id, rule.serve.splits, rule.serve.bucketBy, ctx);
|
|
279
|
+
if (outcome._tag === "MissingSubject") return Resolution.error({
|
|
280
|
+
value: flag.variants[flag.defaultVariant] ?? null,
|
|
281
|
+
variant: flag.defaultVariant,
|
|
282
|
+
errorCode: "TARGETING_KEY_MISSING",
|
|
283
|
+
errorMessage: `Flag '${flag.key}' rule '${rule.id}' requires a bucketing key but none was provided`,
|
|
284
|
+
flagMetadata: flag.metadata
|
|
285
|
+
});
|
|
286
|
+
return serveVariant(flag, outcome.variant, "SPLIT");
|
|
287
|
+
}
|
|
288
|
+
return serveVariant(flag, flag.defaultVariant, "STATIC");
|
|
289
|
+
};
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/internal/core.ts
|
|
292
|
+
/**
|
|
293
|
+
* The Effect core of the Toggld Node SDK.
|
|
294
|
+
*
|
|
295
|
+
* This is the canonical implementation; `../index.ts` (the plain Promise API) is
|
|
296
|
+
* a thin facade that runs this under a `ManagedRuntime`. Two evaluation modes
|
|
297
|
+
* share one client surface (ADR-0001):
|
|
298
|
+
*
|
|
299
|
+
* - **Remote** (default): every call hits the flag API's OFREP endpoints, which
|
|
300
|
+
* evaluate the flag against the supplied context and return the resolved
|
|
301
|
+
* value (OpenFeature "remote evaluation").
|
|
302
|
+
* - **Local** (`mode: "local"`): the client backgrounds a **definition sync** —
|
|
303
|
+
* a versioned snapshot fetch (`GET /sync/v1/flags`) plus a live WebSocket
|
|
304
|
+
* stream (`/sync/v1/stream`) with polling fallback — and evaluates flags
|
|
305
|
+
* in-process with `@toggld/engine`. Zero server requests per evaluation; the
|
|
306
|
+
* engine is the same code the server runs, so resolutions are identical.
|
|
307
|
+
*
|
|
308
|
+
* OpenFeature semantics: evaluation never throws. Any failure — network error,
|
|
309
|
+
* timeout, unknown flag, a server-reported error, a value whose type doesn't
|
|
310
|
+
* match the requested getter, or (local mode) no snapshot received yet —
|
|
311
|
+
* resolves to the caller's default value with the details carrying
|
|
312
|
+
* `reason: "ERROR"` and an `errorCode`.
|
|
313
|
+
*/
|
|
314
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
315
|
+
const DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
316
|
+
const isBoolean = (v) => typeof v === "boolean";
|
|
317
|
+
const isString = (v) => typeof v === "string";
|
|
318
|
+
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
319
|
+
const isObject = (v) => valueMatchesType(v, "object");
|
|
320
|
+
const okDetails = (key, s) => ({
|
|
321
|
+
flagKey: key,
|
|
322
|
+
value: s.value,
|
|
323
|
+
variant: s.variant,
|
|
324
|
+
reason: s.reason,
|
|
325
|
+
errorCode: void 0,
|
|
326
|
+
errorMessage: void 0,
|
|
327
|
+
flagMetadata: s.metadata ?? {}
|
|
328
|
+
});
|
|
329
|
+
const wireErrorDetails = (fallback, e) => ({
|
|
330
|
+
flagKey: e.key,
|
|
331
|
+
value: fallback,
|
|
332
|
+
variant: void 0,
|
|
333
|
+
reason: "ERROR",
|
|
334
|
+
errorCode: e.errorCode,
|
|
335
|
+
errorMessage: e.errorDetails,
|
|
336
|
+
flagMetadata: e.metadata ?? {}
|
|
337
|
+
});
|
|
338
|
+
const errorDetails = (key, value, errorCode, errorMessage, flagMetadata = {}) => ({
|
|
339
|
+
flagKey: key,
|
|
340
|
+
value,
|
|
341
|
+
variant: void 0,
|
|
342
|
+
reason: "ERROR",
|
|
343
|
+
errorCode,
|
|
344
|
+
errorMessage,
|
|
345
|
+
flagMetadata
|
|
346
|
+
});
|
|
347
|
+
/** Map an engine {@link Resolution} to evaluation details, OpenFeature-style:
|
|
348
|
+
* an abnormal resolution serves the **caller's** default, not the engine's
|
|
349
|
+
* internal fallback — exactly what the remote wire does on a 400. */
|
|
350
|
+
const resolutionDetails = (key, resolution, fallback) => resolution.errorCode !== void 0 ? errorDetails(key, fallback, resolution.errorCode, resolution.errorMessage ?? "", resolution.flagMetadata) : {
|
|
351
|
+
flagKey: key,
|
|
352
|
+
value: resolution.value,
|
|
353
|
+
variant: resolution.variant,
|
|
354
|
+
reason: resolution.reason,
|
|
355
|
+
errorCode: void 0,
|
|
356
|
+
errorMessage: void 0,
|
|
357
|
+
flagMetadata: resolution.flagMetadata
|
|
358
|
+
};
|
|
359
|
+
/** A readable message for an arbitrary thrown/failed value. */
|
|
360
|
+
const describe = (e) => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" ? e.message : String(e);
|
|
361
|
+
/** Map a transport/decoding failure to an OpenFeature error code. */
|
|
362
|
+
const codeFor = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag === "SchemaError" ? "PARSE_ERROR" : "GENERAL";
|
|
363
|
+
/**
|
|
364
|
+
* The Toggld client as an Effect service. Build a {@link layer} from options and
|
|
365
|
+
* compose it into your runtime, then `yield* ToggldClient` to evaluate flags.
|
|
366
|
+
*/
|
|
367
|
+
var ToggldClient = class extends Context.Service()("@toggld/node/ToggldClient") {};
|
|
368
|
+
const makeRemote = (http, mergedContext, timeout) => {
|
|
369
|
+
const decode = HttpClientResponse.schemaBodyJson;
|
|
370
|
+
const evaluate = (key, defaultValue, context) => Effect.gen(function* () {
|
|
371
|
+
const request = HttpClientRequest.post(`/ofrep/v1/evaluate/flags/${encodeURIComponent(key)}`).pipe(HttpClientRequest.bodyJsonUnsafe({ context: mergedContext(context) }));
|
|
372
|
+
const response = yield* http.execute(request);
|
|
373
|
+
return yield* HttpClientResponse.matchStatus(response, {
|
|
374
|
+
"2xx": (r) => Effect.map(decode(OfrepSuccess)(r), (s) => okDetails(key, s)),
|
|
375
|
+
"4xx": (r) => Effect.map(decode(OfrepFailure)(r), (e) => wireErrorDetails(defaultValue, e)),
|
|
376
|
+
orElse: (r) => Effect.succeed(errorDetails(key, defaultValue, "GENERAL", `Unexpected response status ${r.status}`))
|
|
377
|
+
});
|
|
378
|
+
}).pipe(Effect.timeout(timeout), Effect.catch((error) => Effect.succeed(errorDetails(key, defaultValue, codeFor(error), describe(error)))));
|
|
379
|
+
const evaluateAll = (context) => Effect.gen(function* () {
|
|
380
|
+
const request = HttpClientRequest.post("/ofrep/v1/evaluate/flags").pipe(HttpClientRequest.bodyJsonUnsafe({ context: mergedContext(context) }));
|
|
381
|
+
const response = yield* http.execute(request);
|
|
382
|
+
return (yield* HttpClientResponse.matchStatus(response, {
|
|
383
|
+
"2xx": (r) => decode(OfrepBulkResponse)(r),
|
|
384
|
+
orElse: (r) => Effect.fail(/* @__PURE__ */ new Error(`Unexpected response status ${r.status}`))
|
|
385
|
+
})).flags.map((entry) => "errorCode" in entry ? wireErrorDetails(null, entry) : okDetails(entry.key, entry));
|
|
386
|
+
}).pipe(Effect.timeout(timeout), Effect.catch(() => Effect.succeed([])));
|
|
387
|
+
return {
|
|
388
|
+
ready: Effect.void,
|
|
389
|
+
evaluate,
|
|
390
|
+
evaluateAll
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
const DEFAULT_FLAGS_FILE = "./toggld.flags.json";
|
|
394
|
+
/** The endpoint's `ws(s)://` form for the sync stream. */
|
|
395
|
+
const toStreamUrl = (endpoint) => {
|
|
396
|
+
const url = new URL(endpoint);
|
|
397
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
398
|
+
url.pathname = url.pathname.replace(/\/$/, "") + "/sync/v1/stream";
|
|
399
|
+
return url.toString();
|
|
400
|
+
};
|
|
401
|
+
const makeSnapshotStore = (mergedContext) => Effect.gen(function* () {
|
|
402
|
+
let current;
|
|
403
|
+
let epoch = 0;
|
|
404
|
+
const firstSnapshot = yield* Deferred.make();
|
|
405
|
+
const adopt = (snapshot) => Effect.suspend(() => {
|
|
406
|
+
epoch++;
|
|
407
|
+
current = {
|
|
408
|
+
version: snapshot.version,
|
|
409
|
+
flags: snapshot.flags,
|
|
410
|
+
byKey: new Map(snapshot.flags.map((f) => [f.key, f])),
|
|
411
|
+
segmentsByKey: new Map((snapshot.segments ?? []).map((s) => [s.key, s]))
|
|
412
|
+
};
|
|
413
|
+
return Effect.asVoid(Deferred.succeed(firstSnapshot, void 0));
|
|
414
|
+
});
|
|
415
|
+
const warnedRemoteSegments = /* @__PURE__ */ new Set();
|
|
416
|
+
const memberOf = (state, ctx) => (segmentKey) => {
|
|
417
|
+
const segment = state.segmentsByKey.get(segmentKey);
|
|
418
|
+
if (segment === void 0) return false;
|
|
419
|
+
if (matchSegment(segment, ctx)) return true;
|
|
420
|
+
if (segment.listMode === "remote" && !warnedRemoteSegments.has(segmentKey)) {
|
|
421
|
+
warnedRemoteSegments.add(segmentKey);
|
|
422
|
+
console.warn(`[toggld] Segment '${segmentKey}' uses a remote member list, which is not available to local evaluation yet — treating subjects its rules don't match as non-members.`);
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
};
|
|
426
|
+
const evaluate = (key, defaultValue, context) => Effect.sync(() => {
|
|
427
|
+
const state = current;
|
|
428
|
+
if (state === void 0) return errorDetails(key, defaultValue, "PROVIDER_NOT_READY", "No flag snapshot loaded yet");
|
|
429
|
+
const flag = state.byKey.get(key);
|
|
430
|
+
if (flag === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
|
|
431
|
+
const ctx = mergedContext(context);
|
|
432
|
+
return resolutionDetails(key, evaluateFlag(flag, ctx, memberOf(state, ctx)), defaultValue);
|
|
433
|
+
});
|
|
434
|
+
const evaluateAll = (context) => Effect.sync(() => {
|
|
435
|
+
const state = current;
|
|
436
|
+
if (state === void 0) return [];
|
|
437
|
+
const ctx = mergedContext(context);
|
|
438
|
+
const isMemberOf = memberOf(state, ctx);
|
|
439
|
+
return state.flags.map((flag) => resolutionDetails(flag.key, evaluateFlag(flag, ctx, isMemberOf), null));
|
|
440
|
+
});
|
|
441
|
+
return {
|
|
442
|
+
adopt,
|
|
443
|
+
epoch: () => epoch,
|
|
444
|
+
version: () => current?.version,
|
|
445
|
+
impl: {
|
|
446
|
+
ready: Deferred.await(firstSnapshot),
|
|
447
|
+
evaluate,
|
|
448
|
+
evaluateAll
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
/**
|
|
453
|
+
* The network source (ADR-0012): bootstrap + safety-net poll over
|
|
454
|
+
* `GET /sync/v1/flags`, plus a live WebSocket stream. Forks its fibers into the
|
|
455
|
+
* caller's scope and feeds `store.adopt`.
|
|
456
|
+
*/
|
|
457
|
+
const startNetworkSource = (options, http, store, timeout) => Effect.gen(function* () {
|
|
458
|
+
const transport = options.sync?.transport ?? "ws";
|
|
459
|
+
const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
460
|
+
const fetchSnapshot = Effect.gen(function* () {
|
|
461
|
+
const response = yield* http.execute(HttpClientRequest.get("/sync/v1/flags"));
|
|
462
|
+
return yield* HttpClientResponse.matchStatus(response, {
|
|
463
|
+
"2xx": HttpClientResponse.schemaBodyJson(FlagSnapshot),
|
|
464
|
+
orElse: (r) => Effect.fail(/* @__PURE__ */ new Error(`Unexpected response status ${r.status}`))
|
|
465
|
+
});
|
|
466
|
+
}).pipe(Effect.timeout(timeout));
|
|
467
|
+
const pollOnce = Effect.gen(function* () {
|
|
468
|
+
const before = store.epoch();
|
|
469
|
+
const snapshot = yield* fetchSnapshot;
|
|
470
|
+
if (store.epoch() !== before) return;
|
|
471
|
+
if (store.version() === snapshot.version) return;
|
|
472
|
+
yield* store.adopt(snapshot);
|
|
473
|
+
});
|
|
474
|
+
const ticker = Effect.gen(function* () {
|
|
475
|
+
let delay = 250;
|
|
476
|
+
while (store.version() === void 0) {
|
|
477
|
+
yield* pollOnce.pipe(Effect.ignore);
|
|
478
|
+
if (store.version() !== void 0) break;
|
|
479
|
+
yield* Effect.sleep(Duration.millis(delay));
|
|
480
|
+
delay = Math.min(delay * 2, 5e3);
|
|
481
|
+
}
|
|
482
|
+
yield* Effect.forever(Effect.sleep(Duration.millis(pollIntervalMs)).pipe(Effect.andThen(Effect.ignore(pollOnce))));
|
|
483
|
+
});
|
|
484
|
+
const decodeMessage = Schema.decodeUnknownEffect(SyncServerMessage);
|
|
485
|
+
const handleMessage = (raw) => Effect.gen(function* () {
|
|
486
|
+
const message = yield* decodeMessage(JSON.parse(String(raw)));
|
|
487
|
+
if (store.version() === message.version) return;
|
|
488
|
+
yield* store.adopt(message);
|
|
489
|
+
}).pipe(Effect.ignore);
|
|
490
|
+
const streamUrl = toStreamUrl(options.endpoint ?? "");
|
|
491
|
+
let consecutiveFailures = 0;
|
|
492
|
+
const streamLoop = Effect.gen(function* () {
|
|
493
|
+
const socket = yield* Socket.makeWebSocket(streamUrl, {
|
|
494
|
+
protocols: [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey ?? ""}`],
|
|
495
|
+
openTimeout: timeout
|
|
496
|
+
});
|
|
497
|
+
yield* Effect.forever(Effect.gen(function* () {
|
|
498
|
+
yield* socket.runRaw(handleMessage, { onOpen: Effect.sync(() => {
|
|
499
|
+
consecutiveFailures = 0;
|
|
500
|
+
}) }).pipe(Effect.ignore);
|
|
501
|
+
consecutiveFailures++;
|
|
502
|
+
const base = Math.min(3e4, 1e3 * 2 ** Math.min(consecutiveFailures - 1, 5));
|
|
503
|
+
yield* Effect.sleep(Duration.millis(base * (.5 + Math.random() * .5)));
|
|
504
|
+
}));
|
|
505
|
+
});
|
|
506
|
+
yield* Effect.forkScoped(ticker);
|
|
507
|
+
if (transport === "ws") yield* Effect.forkScoped(streamLoop);
|
|
508
|
+
});
|
|
509
|
+
/**
|
|
510
|
+
* The file source (ADR-0013): read a `FlagSnapshot` from disk, watch it for
|
|
511
|
+
* edits, and adopt on change. The envelope keys a `pull`-generated file carries
|
|
512
|
+
* (`$schema`, `toggld`) are ignored by the snapshot decoder. No network, key, or
|
|
513
|
+
* stream — purely local. Forks its fibers into the caller's scope.
|
|
514
|
+
*/
|
|
515
|
+
const startFileSource = (path, store, options) => Effect.gen(function* () {
|
|
516
|
+
const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
517
|
+
const decodeSnapshot = Schema.decodeUnknownEffect(FlagSnapshot);
|
|
518
|
+
let lastError;
|
|
519
|
+
const readOnce = Effect.gen(function* () {
|
|
520
|
+
const text = yield* Effect.tryPromise(() => readFile(path, "utf8"));
|
|
521
|
+
const snapshot = yield* decodeSnapshot(JSON.parse(text));
|
|
522
|
+
lastError = void 0;
|
|
523
|
+
if (store.version() === snapshot.version) return;
|
|
524
|
+
yield* store.adopt(snapshot);
|
|
525
|
+
}).pipe(Effect.catch((error) => Effect.sync(() => {
|
|
526
|
+
const message = describe(error);
|
|
527
|
+
if (message === lastError) return;
|
|
528
|
+
lastError = message;
|
|
529
|
+
console.warn(`[toggld] Could not load flags file '${path}': ${message}`);
|
|
530
|
+
})));
|
|
531
|
+
const ticker = Effect.gen(function* () {
|
|
532
|
+
let delay = 100;
|
|
533
|
+
while (store.version() === void 0) {
|
|
534
|
+
yield* readOnce;
|
|
535
|
+
if (store.version() !== void 0) break;
|
|
536
|
+
yield* Effect.sleep(Duration.millis(delay));
|
|
537
|
+
delay = Math.min(delay * 2, 2e3);
|
|
538
|
+
}
|
|
539
|
+
yield* Effect.forever(Effect.sleep(Duration.millis(pollIntervalMs)).pipe(Effect.andThen(readOnce)));
|
|
540
|
+
});
|
|
541
|
+
const dir = dirname(path);
|
|
542
|
+
const base = basename(path);
|
|
543
|
+
const controller = new AbortController();
|
|
544
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()));
|
|
545
|
+
const watchLoop = Effect.gen(function* () {
|
|
546
|
+
while (!controller.signal.aborted) {
|
|
547
|
+
const iterator = yield* Effect.sync(() => {
|
|
548
|
+
try {
|
|
549
|
+
return watch(dir, { signal: controller.signal })[Symbol.asyncIterator]();
|
|
550
|
+
} catch {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
if (iterator === void 0) {
|
|
555
|
+
yield* Effect.sleep(Duration.millis(1e3));
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
let live = true;
|
|
559
|
+
while (live) {
|
|
560
|
+
const next = yield* Effect.tryPromise(() => iterator.next()).pipe(Effect.catch(() => Effect.succeed({
|
|
561
|
+
done: true,
|
|
562
|
+
value: void 0
|
|
563
|
+
})));
|
|
564
|
+
if (next.done) {
|
|
565
|
+
live = false;
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
const filename = next.value?.filename;
|
|
569
|
+
if (filename === base || filename === null || filename === void 0) yield* readOnce;
|
|
570
|
+
}
|
|
571
|
+
if (controller.signal.aborted) break;
|
|
572
|
+
yield* Effect.sleep(Duration.millis(500));
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
yield* Effect.forkScoped(ticker);
|
|
576
|
+
yield* Effect.forkScoped(watchLoop);
|
|
577
|
+
});
|
|
578
|
+
/**
|
|
579
|
+
* Resolve a local flag-file path, or `undefined` for network sync. Highest
|
|
580
|
+
* precedence first: the `TOGGLD_FLAGS_FILE` env var (so a deployed app can be
|
|
581
|
+
* redirected at a file with no code change), then an explicit `sync.file`, then
|
|
582
|
+
* `./toggld.flags.json` when `sync.file` is `true`.
|
|
583
|
+
*/
|
|
584
|
+
const resolveFilePath = (options) => {
|
|
585
|
+
const env = process.env.TOGGLD_FLAGS_FILE;
|
|
586
|
+
if (typeof env === "string" && env.length > 0) return env;
|
|
587
|
+
const file = options.sync?.file;
|
|
588
|
+
if (typeof file === "string" && file.length > 0) return file;
|
|
589
|
+
if (file === true) return DEFAULT_FLAGS_FILE;
|
|
590
|
+
};
|
|
591
|
+
/**
|
|
592
|
+
* Assemble the local-mode evaluation surface, choosing the file source when a
|
|
593
|
+
* path resolves (env var or `sync.file`) and the network source otherwise.
|
|
594
|
+
*/
|
|
595
|
+
const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function* () {
|
|
596
|
+
const store = yield* makeSnapshotStore(mergedContext);
|
|
597
|
+
const filePath = resolveFilePath(options);
|
|
598
|
+
if (filePath !== void 0) yield* startFileSource(filePath, store, options);
|
|
599
|
+
else yield* startNetworkSource(options, http, store, timeout);
|
|
600
|
+
return store.impl;
|
|
601
|
+
});
|
|
602
|
+
const make = (options) => Effect.gen(function* () {
|
|
603
|
+
const base = yield* HttpClient.HttpClient;
|
|
604
|
+
const timeout = Duration.millis(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
605
|
+
const http = base.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(options.endpoint ?? "")), HttpClient.mapRequest(HttpClientRequest.bearerToken(options.sdkKey ?? "")));
|
|
606
|
+
const mergedContext = (context) => ({
|
|
607
|
+
...options.defaultContext,
|
|
608
|
+
...context
|
|
609
|
+
});
|
|
610
|
+
const { evaluate, evaluateAll, ready } = options.mode === "local" || resolveFilePath(options) !== void 0 ? yield* makeLocal(options, http, mergedContext, timeout) : makeRemote(http, mergedContext, timeout);
|
|
611
|
+
/** Resolve, then enforce the requested value type (else `TYPE_MISMATCH`). */
|
|
612
|
+
const typed = (key, defaultValue, context, type, guard) => Effect.map(evaluate(key, defaultValue, context), (d) => {
|
|
613
|
+
if (d.errorCode !== void 0) return d;
|
|
614
|
+
if (guard(d.value)) return d;
|
|
615
|
+
return errorDetails(key, defaultValue, "TYPE_MISMATCH", `Flag '${key}' value is not of type ${type}`, d.flagMetadata);
|
|
616
|
+
});
|
|
617
|
+
const valueOf = (details) => Effect.map(details, (d) => d.value);
|
|
618
|
+
return {
|
|
619
|
+
ready,
|
|
620
|
+
evaluate,
|
|
621
|
+
evaluateAll,
|
|
622
|
+
getBooleanDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "boolean", isBoolean),
|
|
623
|
+
getBooleanValue: (key, defaultValue, context) => valueOf(typed(key, defaultValue, context, "boolean", isBoolean)),
|
|
624
|
+
getStringDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "string", isString),
|
|
625
|
+
getStringValue: (key, defaultValue, context) => valueOf(typed(key, defaultValue, context, "string", isString)),
|
|
626
|
+
getNumberDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "number", isNumber),
|
|
627
|
+
getNumberValue: (key, defaultValue, context) => valueOf(typed(key, defaultValue, context, "number", isNumber)),
|
|
628
|
+
getObjectDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "object", isObject),
|
|
629
|
+
getObjectValue: (key, defaultValue, context) => valueOf(typed(key, defaultValue, context, "object", isObject))
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
/** The HttpClient layer, optionally backed by a caller-supplied `fetch`. */
|
|
633
|
+
const httpLayer = (fetchImpl) => fetchImpl ? FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImpl))) : FetchHttpClient.layer;
|
|
634
|
+
/** The WebSocket constructor for the sync stream, optionally caller-supplied. */
|
|
635
|
+
const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructor, (url, protocols) => new ctor(url, protocols)) : Socket.layerWebSocketConstructorGlobal;
|
|
636
|
+
/**
|
|
637
|
+
* A self-contained `Layer` providing {@link ToggldClient} — it wires its own
|
|
638
|
+
* HTTP and WebSocket backends, so Effect users need only provide this one
|
|
639
|
+
* layer. In local mode the background sync fibers live in the layer's scope:
|
|
640
|
+
* closing it (e.g. `ManagedRuntime.dispose`) stops sync and closes the stream.
|
|
641
|
+
*/
|
|
642
|
+
const layer = (options) => Layer.effect(ToggldClient, make(options)).pipe(Layer.provide(httpLayer(options.fetch)), Layer.provide(webSocketLayer(options.sync?.webSocketConstructor)));
|
|
643
|
+
//#endregion
|
|
644
|
+
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-CPtRV5-5.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-DBVGNtur.mjs";
|
|
2
2
|
export { ToggldClient, layer };
|