@toggld/node 0.2.0 → 0.4.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 +109 -10
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +235 -0
- package/dist/{core-Bx0PVoEm.mjs → core-CezJWPV4.mjs} +280 -512
- package/dist/effect.d.mts +6 -7
- package/dist/effect.mjs +1 -2
- package/dist/index.d.mts +5 -6
- package/dist/index.mjs +8 -7
- package/dist/src-B_M-Oix8.mjs +638 -0
- package/dist/{types-DItF1mIU.d.mts → types-hdTQWEM5.d.mts} +41 -12
- package/package.json +7 -4
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
1
|
+
import { a as OfrepSuccess, i as OfrepFailure, n as SyncServerMessage, r as OfrepBulkResponse, t as FlagSnapshot } from "./src-B_M-Oix8.mjs";
|
|
2
|
+
import { Context, Deferred, Duration, Effect, Layer, Schema, Semaphore } 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/http";
|
|
6
|
+
import { Socket } from "effect/socket";
|
|
7
|
+
// ../spec/src/index.ts
|
|
5
8
|
/** True when `value` is assignable to a flag declared as `type`. */
|
|
6
9
|
const valueMatchesType = (value, type) => {
|
|
7
10
|
switch (type) {
|
|
@@ -53,8 +56,7 @@ const Resolution = {
|
|
|
53
56
|
flagMetadata: args.flagMetadata ?? {}
|
|
54
57
|
})
|
|
55
58
|
};
|
|
56
|
-
|
|
57
|
-
//#region ../engine/src/hash.ts
|
|
59
|
+
// ../engine/src/hash.ts
|
|
58
60
|
/**
|
|
59
61
|
* MurmurHash3 (x86, 32-bit). A fast, well-distributed, non-cryptographic hash.
|
|
60
62
|
* Used to deterministically bucket a subject into a percentage rollout slice so
|
|
@@ -112,8 +114,7 @@ const BUCKET_SCALE = 1e4;
|
|
|
112
114
|
const bucketFor = (flagKey, ruleId, subject) => {
|
|
113
115
|
return murmur3(`${flagKey}:${ruleId}:${subject}`) % BUCKET_SCALE;
|
|
114
116
|
};
|
|
115
|
-
|
|
116
|
-
//#region ../engine/src/targeting.ts
|
|
117
|
+
// ../engine/src/targeting.ts
|
|
117
118
|
const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
118
119
|
const asString = (v) => typeof v === "string" ? v : void 0;
|
|
119
120
|
/** Evaluates a single condition against the context. */
|
|
@@ -149,8 +150,37 @@ const matchCondition = (condition, ctx) => {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
};
|
|
152
|
-
/**
|
|
153
|
-
|
|
153
|
+
/**
|
|
154
|
+
* A rule matches when every one of its conditions matches **and** every
|
|
155
|
+
* referenced segment resolves (member XOR `negate`) through `isMemberOf`
|
|
156
|
+
* (logical AND throughout). A rule with no segment references behaves exactly as
|
|
157
|
+
* conditions-only. `isMemberOf` is consulted only when the rule has segments;
|
|
158
|
+
* when absent, any segment reference resolves `false` (closed).
|
|
159
|
+
*/
|
|
160
|
+
const matchRule = (rule, ctx, isMemberOf) => {
|
|
161
|
+
if (!rule.conditions.every((c) => matchCondition(c, ctx))) return false;
|
|
162
|
+
if (rule.segments === void 0 || rule.segments.length === 0) return true;
|
|
163
|
+
return rule.segments.every((ref) => {
|
|
164
|
+
const member = isMemberOf?.(ref.segment) ?? false;
|
|
165
|
+
return ref.negate ? !member : member;
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Resolves segment membership for a subject from a synced segment *definition*
|
|
170
|
+
* (ADR-0006) — the local path for rule-based and `inline`-list segments. Pure
|
|
171
|
+
* and isomorphic: the server and the Node SDK build their `isMemberOf` over
|
|
172
|
+
* this. Membership is the union of a rule match (OR of rules, each an AND of
|
|
173
|
+
* conditions) and presence in the inline member list (keyed by `keyAttribute`).
|
|
174
|
+
* `remote` segments carry no `members` here and must be resolved out of band.
|
|
175
|
+
*/
|
|
176
|
+
const matchSegment = (segment, ctx) => {
|
|
177
|
+
if (segment.rules.some((r) => r.conditions.every((c) => matchCondition(c, ctx)))) return true;
|
|
178
|
+
if (segment.members !== void 0 && segment.members.length > 0) {
|
|
179
|
+
const subject = attributeOf(ctx, segment.keyAttribute);
|
|
180
|
+
if (typeof subject === "string" && segment.members.includes(subject)) return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
};
|
|
154
184
|
/**
|
|
155
185
|
* Selects a rollout variant for a subject. Weights are normalised across all
|
|
156
186
|
* splits, and the subject's bucket (consistent for the flag/rule/subject) is
|
|
@@ -202,8 +232,7 @@ const deepEqual = (a, b) => {
|
|
|
202
232
|
}
|
|
203
233
|
return false;
|
|
204
234
|
};
|
|
205
|
-
|
|
206
|
-
//#region ../engine/src/evaluate.ts
|
|
235
|
+
// ../engine/src/evaluate.ts
|
|
207
236
|
/**
|
|
208
237
|
* Looks up a variant's value and returns a successful resolution, or an
|
|
209
238
|
* abnormal `GENERAL` resolution if the variant is not declared (a
|
|
@@ -233,11 +262,15 @@ const serveVariant = (flag, variant, reason) => {
|
|
|
233
262
|
* - fixed variant → `TARGETING_MATCH`
|
|
234
263
|
* - rollout → `SPLIT` (or `TARGETING_KEY_MISSING` if no subject)
|
|
235
264
|
* 3. no rule matches → `defaultVariant` with reason `STATIC`
|
|
265
|
+
*
|
|
266
|
+
* `isMemberOf` resolves any {@link SegmentRef} a rule references (ADR-0006). It
|
|
267
|
+
* is optional: flags with no segment references never call it, and when it is
|
|
268
|
+
* omitted a segment reference resolves `false` (closed).
|
|
236
269
|
*/
|
|
237
|
-
const evaluateFlag = (flag, ctx) => {
|
|
270
|
+
const evaluateFlag = (flag, ctx, isMemberOf) => {
|
|
238
271
|
if (!flag.enabled) return serveVariant(flag, flag.offVariant ?? flag.defaultVariant, "DISABLED");
|
|
239
272
|
for (const rule of flag.targeting) {
|
|
240
|
-
if (!matchRule(rule, ctx)) continue;
|
|
273
|
+
if (!matchRule(rule, ctx, isMemberOf)) continue;
|
|
241
274
|
if (rule.serve.type === "variant") return serveVariant(flag, rule.serve.variant, "TARGETING_MATCH");
|
|
242
275
|
const outcome = selectRolloutVariant(flag.key, rule.id, rule.serve.splits, rule.serve.bucketBy, ctx);
|
|
243
276
|
if (outcome._tag === "MissingSubject") return Resolution.error({
|
|
@@ -251,468 +284,65 @@ const evaluateFlag = (flag, ctx) => {
|
|
|
251
284
|
}
|
|
252
285
|
return serveVariant(flag, flag.defaultVariant, "STATIC");
|
|
253
286
|
};
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
*
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
});
|
|
312
|
-
//#endregion
|
|
313
|
-
//#region ../schema/src/auth.ts
|
|
314
|
-
/**
|
|
315
|
-
* Authorization role carried by an API key.
|
|
316
|
-
*
|
|
317
|
-
* - `management` — full CRUD on the tenant's flags, plus evaluation.
|
|
318
|
-
* - `evaluation` — read-only evaluation (OFREP) of the tenant's flags.
|
|
319
|
-
*/
|
|
320
|
-
const Role = Schema.Literals(["management", "evaluation"]);
|
|
321
|
-
Schema.Struct({
|
|
322
|
-
id: Schema.String,
|
|
323
|
-
name: Schema.String,
|
|
324
|
-
createdAt: Schema.String,
|
|
325
|
-
/**
|
|
326
|
-
* Max number of flag *definitions* the tenant may hold; `null` = unlimited.
|
|
327
|
-
* The free tier is 10 (the DB column default). The flag API treats this as an
|
|
328
|
-
* opaque number — the web app owns the plan→limit mapping and pushes changes
|
|
329
|
-
* over the control plane (provision + PATCH). Enforced in `FlagService.create`.
|
|
330
|
-
*/
|
|
331
|
-
flagLimit: Schema.NullOr(Schema.Int)
|
|
332
|
-
});
|
|
333
|
-
Schema.Struct({
|
|
334
|
-
id: Schema.String,
|
|
335
|
-
tenantId: Schema.String,
|
|
336
|
-
/** Stable slug, unique within the tenant (e.g. `production`). */
|
|
337
|
-
key: Schema.String,
|
|
338
|
-
name: Schema.String,
|
|
339
|
-
createdAt: Schema.String
|
|
340
|
-
});
|
|
341
|
-
/** Body for creating an environment (POST /admin/v1/tenants/:tenantId/environments). */
|
|
342
|
-
const CreateEnvironmentInput = Schema.Struct({
|
|
343
|
-
key: Schema.String.check(Schema.isMinLength(1)),
|
|
344
|
-
name: Schema.String.check(Schema.isMinLength(1))
|
|
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
|
-
});
|
|
355
|
-
/**
|
|
356
|
-
* Public view of an API key — safe to list. Never includes the secret token or
|
|
357
|
-
* its hash; only a non-secret prefix for human identification.
|
|
358
|
-
*/
|
|
359
|
-
const ApiKeyInfo = Schema.Struct({
|
|
360
|
-
id: Schema.String,
|
|
361
|
-
tenantId: Schema.String,
|
|
362
|
-
/** The single environment this key is scoped to. */
|
|
363
|
-
environmentId: Schema.String,
|
|
364
|
-
name: Schema.optionalKey(Schema.String),
|
|
365
|
-
role: Role,
|
|
366
|
-
prefix: Schema.String,
|
|
367
|
-
createdAt: Schema.String
|
|
368
|
-
});
|
|
369
|
-
Schema.Struct({
|
|
370
|
-
...ApiKeyInfo.fields,
|
|
371
|
-
token: Schema.String
|
|
372
|
-
});
|
|
373
|
-
Schema.Struct({
|
|
374
|
-
id: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1))),
|
|
375
|
-
name: Schema.String.check(Schema.isMinLength(1)),
|
|
376
|
-
environments: Schema.optionalKey(Schema.Array(CreateEnvironmentInput)),
|
|
377
|
-
/** Flag cap for the new tenant; omitted → the free-tier default (10). */
|
|
378
|
-
flagLimit: Schema.optionalKey(Schema.Int)
|
|
379
|
-
});
|
|
380
|
-
Schema.Struct({
|
|
381
|
-
name: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1))),
|
|
382
|
-
flagLimit: Schema.optionalKey(Schema.NullOr(Schema.Int))
|
|
383
|
-
});
|
|
384
|
-
Schema.Struct({
|
|
385
|
-
role: Role,
|
|
386
|
-
name: Schema.optionalKey(Schema.String),
|
|
387
|
-
environment: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1)))
|
|
388
|
-
});
|
|
389
|
-
//#endregion
|
|
390
|
-
//#region ../schema/src/value.ts
|
|
391
|
-
/**
|
|
392
|
-
* The OpenFeature flag value types. Every flag declares one of these; variant
|
|
393
|
-
* values and resolved values are validated against it.
|
|
394
|
-
*
|
|
395
|
-
* @see https://openfeature.dev/specification/types
|
|
396
|
-
*/
|
|
397
|
-
const FlagType = Schema.Literals([
|
|
398
|
-
"boolean",
|
|
399
|
-
"string",
|
|
400
|
-
"number",
|
|
401
|
-
"object"
|
|
402
|
-
]);
|
|
403
|
-
/**
|
|
404
|
-
* A resolved flag value. OpenFeature permits `boolean | string | number |
|
|
405
|
-
* structure`; we store the union as JSON, which also covers arrays and null
|
|
406
|
-
* for "object" typed flags.
|
|
407
|
-
*/
|
|
408
|
-
const FlagValue = Schema.Json;
|
|
409
|
-
/**
|
|
410
|
-
* Flag metadata: "a structure supporting the definition of arbitrary
|
|
411
|
-
* properties, with keys of type string, and values of type boolean, string,
|
|
412
|
-
* or number." (Requirement 2.2.10)
|
|
413
|
-
*/
|
|
414
|
-
const MetadataValue = Schema.Union([
|
|
415
|
-
Schema.String,
|
|
416
|
-
Schema.Finite,
|
|
417
|
-
Schema.Boolean
|
|
418
|
-
]);
|
|
419
|
-
const FlagMetadata = Schema.Record(Schema.String, MetadataValue);
|
|
420
|
-
//#endregion
|
|
421
|
-
//#region ../schema/src/context.ts
|
|
422
|
-
/**
|
|
423
|
-
* An OpenFeature evaluation context: an optional `targetingKey` "identifying
|
|
424
|
-
* the subject of the flag evaluation" (Requirement 3.1.1) plus arbitrary
|
|
425
|
-
* custom fields (Requirement 3.1.2). We accept any JSON-valued attributes.
|
|
426
|
-
*/
|
|
427
|
-
const EvaluationContext = Schema.Record(Schema.String, FlagValue);
|
|
428
|
-
Schema.ErrorClass("FlagNotFound")({
|
|
429
|
-
_tag: Schema.tag("FlagNotFound"),
|
|
430
|
-
flagKey: Schema.String,
|
|
431
|
-
message: Schema.String
|
|
432
|
-
});
|
|
433
|
-
Schema.ErrorClass("FlagAlreadyExists")({
|
|
434
|
-
_tag: Schema.tag("FlagAlreadyExists"),
|
|
435
|
-
flagKey: Schema.String,
|
|
436
|
-
message: Schema.String
|
|
437
|
-
});
|
|
438
|
-
Schema.ErrorClass("FlagValidationError")({
|
|
439
|
-
_tag: Schema.tag("FlagValidationError"),
|
|
440
|
-
flagKey: Schema.String,
|
|
441
|
-
issues: Schema.Array(Schema.String),
|
|
442
|
-
message: Schema.String
|
|
443
|
-
});
|
|
444
|
-
Schema.ErrorClass("FlagLimitExceeded")({
|
|
445
|
-
_tag: Schema.tag("FlagLimitExceeded"),
|
|
446
|
-
/** The tenant's current flag cap. */
|
|
447
|
-
limit: Schema.Int,
|
|
448
|
-
/** How many flags the tenant has now (>= limit). */
|
|
449
|
-
current: Schema.Int,
|
|
450
|
-
message: Schema.String
|
|
451
|
-
});
|
|
452
|
-
Schema.ErrorClass("Unauthorized")({
|
|
453
|
-
_tag: Schema.tag("Unauthorized"),
|
|
454
|
-
message: Schema.String
|
|
455
|
-
});
|
|
456
|
-
Schema.ErrorClass("Forbidden")({
|
|
457
|
-
_tag: Schema.tag("Forbidden"),
|
|
458
|
-
message: Schema.String
|
|
459
|
-
});
|
|
460
|
-
Schema.ErrorClass("TenantNotFound")({
|
|
461
|
-
_tag: Schema.tag("TenantNotFound"),
|
|
462
|
-
tenantId: Schema.String,
|
|
463
|
-
message: Schema.String
|
|
464
|
-
});
|
|
465
|
-
Schema.ErrorClass("TenantAlreadyExists")({
|
|
466
|
-
_tag: Schema.tag("TenantAlreadyExists"),
|
|
467
|
-
tenantId: Schema.String,
|
|
468
|
-
message: Schema.String
|
|
469
|
-
});
|
|
470
|
-
Schema.ErrorClass("EnvironmentNotFound")({
|
|
471
|
-
_tag: Schema.tag("EnvironmentNotFound"),
|
|
472
|
-
environment: Schema.String,
|
|
473
|
-
message: Schema.String
|
|
474
|
-
});
|
|
475
|
-
Schema.ErrorClass("EnvironmentAlreadyExists")({
|
|
476
|
-
_tag: Schema.tag("EnvironmentAlreadyExists"),
|
|
477
|
-
environment: Schema.String,
|
|
478
|
-
message: Schema.String
|
|
479
|
-
});
|
|
480
|
-
Schema.ErrorClass("ApiKeyNotFound")({
|
|
481
|
-
_tag: Schema.tag("ApiKeyNotFound"),
|
|
482
|
-
keyId: Schema.String,
|
|
483
|
-
message: Schema.String
|
|
484
|
-
});
|
|
485
|
-
//#endregion
|
|
486
|
-
//#region ../schema/src/flag.ts
|
|
487
|
-
/**
|
|
488
|
-
* Operators usable in a targeting condition. Comparisons coerce sensibly:
|
|
489
|
-
* numeric operators require numbers, string operators require strings, and
|
|
490
|
-
* `in`/`notIn` test membership in an array literal.
|
|
491
|
-
*/
|
|
492
|
-
const Operator = Schema.Literals([
|
|
493
|
-
"eq",
|
|
494
|
-
"ne",
|
|
495
|
-
"in",
|
|
496
|
-
"notIn",
|
|
497
|
-
"lt",
|
|
498
|
-
"lte",
|
|
499
|
-
"gt",
|
|
500
|
-
"gte",
|
|
501
|
-
"contains",
|
|
502
|
-
"startsWith",
|
|
503
|
-
"endsWith",
|
|
504
|
-
"exists",
|
|
505
|
-
"notExists"
|
|
506
|
-
]);
|
|
507
|
-
/**
|
|
508
|
-
* A single targeting condition tested against an attribute of the evaluation
|
|
509
|
-
* context (e.g. `targetingKey`, `email`, `plan`). All conditions in a rule are
|
|
510
|
-
* ANDed together.
|
|
511
|
-
*/
|
|
512
|
-
const Condition = Schema.Struct({
|
|
513
|
-
attribute: Schema.String,
|
|
514
|
-
operator: Operator,
|
|
515
|
-
/** Comparison operand. Ignored for `exists`/`notExists`. */
|
|
516
|
-
value: Schema.optionalKey(FlagValue)
|
|
517
|
-
});
|
|
518
|
-
/** A weighted slice of a percentage rollout. */
|
|
519
|
-
const Split = Schema.Struct({
|
|
520
|
-
variant: Schema.String,
|
|
521
|
-
/** Relative weight; the engine normalises weights across all splits. */
|
|
522
|
-
weight: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
|
|
523
|
-
});
|
|
524
|
-
/** Serve a single fixed variant when the rule matches → `TARGETING_MATCH`. */
|
|
525
|
-
const ServeVariant = Schema.Struct({
|
|
526
|
-
type: Schema.Literal("variant"),
|
|
527
|
-
variant: Schema.String
|
|
528
|
-
});
|
|
529
|
-
/**
|
|
530
|
-
* Serve a deterministic percentage rollout when the rule matches → `SPLIT`.
|
|
531
|
-
* Bucketing is consistent for a given subject (`bucketBy`, default
|
|
532
|
-
* `targetingKey`) so a subject always lands in the same slice.
|
|
533
|
-
*/
|
|
534
|
-
const ServeRollout = Schema.Struct({
|
|
535
|
-
type: Schema.Literal("rollout"),
|
|
536
|
-
bucketBy: Schema.optionalKey(Schema.String),
|
|
537
|
-
splits: Schema.Array(Split)
|
|
538
|
-
});
|
|
539
|
-
const Serve = Schema.Union([ServeVariant, ServeRollout]);
|
|
540
|
-
/** An ordered targeting rule. The first rule whose conditions all match wins. */
|
|
541
|
-
const Rule = Schema.Struct({
|
|
542
|
-
id: Schema.String,
|
|
543
|
-
description: Schema.optionalKey(Schema.String),
|
|
544
|
-
/** ANDed conditions. An empty list always matches (useful as a catch-all). */
|
|
545
|
-
conditions: Schema.Array(Condition),
|
|
546
|
-
serve: Serve
|
|
547
|
-
});
|
|
548
|
-
/**
|
|
549
|
-
* A feature flag definition.
|
|
550
|
-
*
|
|
551
|
-
* Evaluation order: if `enabled` is false → serve `offVariant` (default
|
|
552
|
-
* `defaultVariant`) with reason `DISABLED`; otherwise walk `targeting` in
|
|
553
|
-
* order and serve the first matching rule (`TARGETING_MATCH`/`SPLIT`); if no
|
|
554
|
-
* rule matches, serve `defaultVariant` with reason `STATIC`.
|
|
555
|
-
*/
|
|
556
|
-
const Flag = Schema.Struct({
|
|
557
|
-
key: Schema.String,
|
|
558
|
-
name: Schema.optionalKey(Schema.String),
|
|
559
|
-
description: Schema.optionalKey(Schema.String),
|
|
560
|
-
type: FlagType,
|
|
561
|
-
enabled: Schema.Boolean,
|
|
562
|
-
/** Named values; every served variant name must be a key here. */
|
|
563
|
-
variants: Schema.Record(Schema.String, FlagValue),
|
|
564
|
-
/** Variant served on a normal evaluation with no targeting match. */
|
|
565
|
-
defaultVariant: Schema.String,
|
|
566
|
-
/** Variant served when the flag is disabled. Defaults to `defaultVariant`. */
|
|
567
|
-
offVariant: Schema.optionalKey(Schema.String),
|
|
568
|
-
targeting: Schema.Array(Rule),
|
|
569
|
-
metadata: FlagMetadata,
|
|
570
|
-
createdAt: Schema.String,
|
|
571
|
-
updatedAt: Schema.String
|
|
572
|
-
});
|
|
573
|
-
/**
|
|
574
|
-
* Freeform organization labels for a flag (ADR-0008). A flag may carry any
|
|
575
|
-
* number; they group/filter flags in the dashboard and have **no** effect on
|
|
576
|
-
* evaluation. Deliberately separate from the spec `Flag` (and `metadata`) so
|
|
577
|
-
* they never reach the OFREP wire or the engine.
|
|
578
|
-
*/
|
|
579
|
-
const Tags = Schema.Array(Schema.String.check(Schema.isMinLength(1)));
|
|
580
|
-
Schema.Struct({
|
|
581
|
-
...Flag.fields,
|
|
582
|
-
tags: Tags
|
|
583
|
-
});
|
|
584
|
-
//#endregion
|
|
585
|
-
//#region ../schema/src/resolution.ts
|
|
586
|
-
/**
|
|
587
|
-
* Resolution reasons. The provider "SHOULD populate the resolution details
|
|
588
|
-
* structure's reason field with STATIC, DEFAULT, TARGETING_MATCH, SPLIT,
|
|
589
|
-
* CACHED, DISABLED, UNKNOWN, STALE, ERROR or some other string."
|
|
590
|
-
* (Requirement 2.2.5)
|
|
591
|
-
*/
|
|
592
|
-
const Reason = Schema.Literals([
|
|
593
|
-
"STATIC",
|
|
594
|
-
"DEFAULT",
|
|
595
|
-
"TARGETING_MATCH",
|
|
596
|
-
"SPLIT",
|
|
597
|
-
"CACHED",
|
|
598
|
-
"DISABLED",
|
|
599
|
-
"UNKNOWN",
|
|
600
|
-
"STALE",
|
|
601
|
-
"ERROR"
|
|
602
|
-
]);
|
|
603
|
-
/**
|
|
604
|
-
* OpenFeature error codes.
|
|
605
|
-
* @see https://openfeature.dev/specification/types
|
|
606
|
-
*/
|
|
607
|
-
const ErrorCode = Schema.Literals([
|
|
608
|
-
"PROVIDER_NOT_READY",
|
|
609
|
-
"PROVIDER_FATAL",
|
|
610
|
-
"FLAG_NOT_FOUND",
|
|
611
|
-
"PARSE_ERROR",
|
|
612
|
-
"TYPE_MISMATCH",
|
|
613
|
-
"TARGETING_KEY_MISSING",
|
|
614
|
-
"INVALID_CONTEXT",
|
|
615
|
-
"GENERAL"
|
|
616
|
-
]);
|
|
617
|
-
Schema.Struct({ context: Schema.optionalKey(EvaluationContext) });
|
|
618
|
-
/** A successful single/bulk evaluation result. */
|
|
619
|
-
const OfrepSuccess = Schema.Struct({
|
|
620
|
-
key: Schema.String,
|
|
621
|
-
value: FlagValue,
|
|
622
|
-
reason: Reason,
|
|
623
|
-
variant: Schema.optionalKey(Schema.String),
|
|
624
|
-
metadata: Schema.optionalKey(FlagMetadata)
|
|
625
|
-
});
|
|
626
|
-
/** A per-flag failure embedded in a bulk response. */
|
|
627
|
-
const OfrepFailure = Schema.Struct({
|
|
628
|
-
key: Schema.String,
|
|
629
|
-
errorCode: ErrorCode,
|
|
630
|
-
errorDetails: Schema.optionalKey(Schema.String),
|
|
631
|
-
metadata: Schema.optionalKey(FlagMetadata)
|
|
632
|
-
});
|
|
633
|
-
/** Bulk evaluation response: a mixed list of successes and failures. */
|
|
634
|
-
const OfrepBulkResponse = Schema.Struct({
|
|
635
|
-
flags: Schema.Array(Schema.Union([OfrepSuccess, OfrepFailure])),
|
|
636
|
-
metadata: Schema.optionalKey(FlagMetadata)
|
|
637
|
-
});
|
|
638
|
-
Schema.ErrorClass("OfrepFlagNotFound")({
|
|
639
|
-
key: Schema.String,
|
|
640
|
-
errorCode: Schema.Literal("FLAG_NOT_FOUND"),
|
|
641
|
-
errorDetails: Schema.optionalKey(Schema.String)
|
|
642
|
-
});
|
|
643
|
-
Schema.ErrorClass("OfrepEvaluationError")({
|
|
644
|
-
key: Schema.String,
|
|
645
|
-
errorCode: ErrorCode,
|
|
646
|
-
errorDetails: Schema.optionalKey(Schema.String)
|
|
647
|
-
});
|
|
648
|
-
Schema.Struct({
|
|
649
|
-
key: Schema.String.check(Schema.isMinLength(1)),
|
|
650
|
-
name: Schema.optionalKey(Schema.String),
|
|
651
|
-
description: Schema.optionalKey(Schema.String),
|
|
652
|
-
type: FlagType,
|
|
653
|
-
enabled: Schema.optionalKey(Schema.Boolean),
|
|
654
|
-
variants: Schema.Record(Schema.String, FlagValue),
|
|
655
|
-
defaultVariant: Schema.String,
|
|
656
|
-
offVariant: Schema.optionalKey(Schema.String),
|
|
657
|
-
targeting: Schema.optionalKey(Schema.Array(Rule)),
|
|
658
|
-
metadata: Schema.optionalKey(FlagMetadata),
|
|
659
|
-
/** Freeform organization labels (ADR-0008). Normalized server-side. */
|
|
660
|
-
tags: Schema.optionalKey(Tags)
|
|
661
|
-
});
|
|
662
|
-
Schema.Struct({
|
|
663
|
-
name: Schema.optionalKey(Schema.String),
|
|
664
|
-
description: Schema.optionalKey(Schema.String),
|
|
665
|
-
type: FlagType,
|
|
666
|
-
enabled: Schema.optionalKey(Schema.Boolean),
|
|
667
|
-
variants: Schema.Record(Schema.String, FlagValue),
|
|
668
|
-
defaultVariant: Schema.String,
|
|
669
|
-
offVariant: Schema.optionalKey(Schema.String),
|
|
670
|
-
targeting: Schema.optionalKey(Schema.Array(Rule)),
|
|
671
|
-
metadata: Schema.optionalKey(FlagMetadata),
|
|
672
|
-
tags: Schema.optionalKey(Tags)
|
|
673
|
-
});
|
|
674
|
-
Schema.Struct({
|
|
675
|
-
name: Schema.optionalKey(Schema.String),
|
|
676
|
-
description: Schema.optionalKey(Schema.String),
|
|
677
|
-
type: Schema.optionalKey(FlagType),
|
|
678
|
-
enabled: Schema.optionalKey(Schema.Boolean),
|
|
679
|
-
variants: Schema.optionalKey(Schema.Record(Schema.String, FlagValue)),
|
|
680
|
-
defaultVariant: Schema.optionalKey(Schema.String),
|
|
681
|
-
offVariant: Schema.optionalKey(Schema.String),
|
|
682
|
-
targeting: Schema.optionalKey(Schema.Array(Rule)),
|
|
683
|
-
metadata: Schema.optionalKey(FlagMetadata),
|
|
684
|
-
tags: Schema.optionalKey(Tags)
|
|
685
|
-
});
|
|
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)
|
|
287
|
+
// src/internal/telemetry.ts
|
|
288
|
+
const startTelemetry = (http, options) => Effect.gen(function* () {
|
|
289
|
+
const instanceId = crypto.randomUUID();
|
|
290
|
+
const lock = yield* Semaphore.make(1);
|
|
291
|
+
const interval = options.telemetry === false ? 3e4 : options.telemetry?.flushIntervalMs ?? 3e4;
|
|
292
|
+
const timeout = Duration.millis(options.timeoutMs ?? 5e3);
|
|
293
|
+
let cells = /* @__PURE__ */ new Map();
|
|
294
|
+
let pending = [];
|
|
295
|
+
const flush = lock.withPermit(Effect.gen(function* () {
|
|
296
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
297
|
+
if (pending.length === 0) {
|
|
298
|
+
const rollups = [...cells.values()];
|
|
299
|
+
cells = /* @__PURE__ */ new Map();
|
|
300
|
+
for (let i = 0; i < rollups.length; i += 1e3) pending.push({
|
|
301
|
+
sdkInstanceId: instanceId,
|
|
302
|
+
windowId: crypto.randomUUID(),
|
|
303
|
+
rollups: rollups.slice(i, i + 1e3)
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
while (pending.length > 0) {
|
|
307
|
+
if (!(yield* Effect.gen(function* () {
|
|
308
|
+
const response = yield* http.execute(HttpClientRequest.post("/v1/telemetry").pipe(HttpClientRequest.bodyJsonUnsafe(pending[0])));
|
|
309
|
+
return response.status >= 200 && response.status < 300;
|
|
310
|
+
}).pipe(Effect.timeout(timeout), Effect.catchCause(() => Effect.succeed(false))))) return;
|
|
311
|
+
pending.shift();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}));
|
|
315
|
+
yield* Effect.addFinalizer(() => flush.pipe(Effect.timeout(timeout), Effect.catchCause(() => Effect.void)));
|
|
316
|
+
yield* Effect.forkScoped(Effect.forever(Effect.sleep(Duration.millis(Number.isFinite(interval) && interval > 0 ? interval : 3e4)).pipe(Effect.andThen(flush))));
|
|
317
|
+
return {
|
|
318
|
+
flush,
|
|
319
|
+
record: (flagKey, resolution) => {
|
|
320
|
+
if (flagKey.length === 0 || flagKey.length > 256 || (resolution.variant?.length ?? 0) > 256) return;
|
|
321
|
+
const variant = resolution.variant ?? "";
|
|
322
|
+
const bucketStart = (/* @__PURE__ */ new Date(Math.floor(Date.now() / 6e4) * 6e4)).toISOString();
|
|
323
|
+
const key = JSON.stringify([
|
|
324
|
+
flagKey,
|
|
325
|
+
variant,
|
|
326
|
+
resolution.reason,
|
|
327
|
+
bucketStart
|
|
328
|
+
]);
|
|
329
|
+
const cell = cells.get(key);
|
|
330
|
+
if (cell) {
|
|
331
|
+
if (cell.count < 1e9) cells.set(key, {
|
|
332
|
+
...cell,
|
|
333
|
+
count: cell.count + 1
|
|
334
|
+
});
|
|
335
|
+
} else if (cells.size < 1e4) cells.set(key, {
|
|
336
|
+
flagKey,
|
|
337
|
+
variant,
|
|
338
|
+
reason: resolution.reason,
|
|
339
|
+
bucketStart,
|
|
340
|
+
count: 1
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
};
|
|
707
344
|
});
|
|
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
|
|
715
|
-
//#region src/internal/core.ts
|
|
345
|
+
// src/internal/core.ts
|
|
716
346
|
/**
|
|
717
347
|
* The Effect core of the Toggld Node SDK.
|
|
718
348
|
*
|
|
@@ -766,19 +396,19 @@ const errorDetails = (key, value, errorCode, errorMessage, flagMetadata = {}) =>
|
|
|
766
396
|
reason: "ERROR",
|
|
767
397
|
errorCode,
|
|
768
398
|
errorMessage,
|
|
769
|
-
flagMetadata
|
|
399
|
+
flagMetadata: { ...flagMetadata }
|
|
770
400
|
});
|
|
771
401
|
/** Map an engine {@link Resolution} to evaluation details, OpenFeature-style:
|
|
772
402
|
* an abnormal resolution serves the **caller's** default, not the engine's
|
|
773
403
|
* internal fallback — exactly what the remote wire does on a 400. */
|
|
774
404
|
const resolutionDetails = (key, resolution, fallback) => resolution.errorCode !== void 0 ? errorDetails(key, fallback, resolution.errorCode, resolution.errorMessage ?? "", resolution.flagMetadata) : {
|
|
775
405
|
flagKey: key,
|
|
776
|
-
value: resolution.value,
|
|
406
|
+
value: typeof resolution.value === "object" && resolution.value !== null ? structuredClone(resolution.value) : resolution.value,
|
|
777
407
|
variant: resolution.variant,
|
|
778
408
|
reason: resolution.reason,
|
|
779
409
|
errorCode: void 0,
|
|
780
410
|
errorMessage: void 0,
|
|
781
|
-
flagMetadata: resolution.flagMetadata
|
|
411
|
+
flagMetadata: { ...resolution.flagMetadata }
|
|
782
412
|
};
|
|
783
413
|
/** A readable message for an arbitrary thrown/failed value. */
|
|
784
414
|
const describe = (e) => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" ? e.message : String(e);
|
|
@@ -814,6 +444,7 @@ const makeRemote = (http, mergedContext, timeout) => {
|
|
|
814
444
|
evaluateAll
|
|
815
445
|
};
|
|
816
446
|
};
|
|
447
|
+
const DEFAULT_FLAGS_FILE = "./toggld.flags.json";
|
|
817
448
|
/** The endpoint's `ws(s)://` form for the sync stream. */
|
|
818
449
|
const toStreamUrl = (endpoint) => {
|
|
819
450
|
const url = new URL(endpoint);
|
|
@@ -821,9 +452,7 @@ const toStreamUrl = (endpoint) => {
|
|
|
821
452
|
url.pathname = url.pathname.replace(/\/$/, "") + "/sync/v1/stream";
|
|
822
453
|
return url.toString();
|
|
823
454
|
};
|
|
824
|
-
const
|
|
825
|
-
const transport = options.sync?.transport ?? "ws";
|
|
826
|
-
const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
455
|
+
const makeSnapshotStore = (mergedContext, record) => Effect.gen(function* () {
|
|
827
456
|
let current;
|
|
828
457
|
let epoch = 0;
|
|
829
458
|
const firstSnapshot = yield* Deferred.make();
|
|
@@ -832,10 +461,62 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
|
|
|
832
461
|
current = {
|
|
833
462
|
version: snapshot.version,
|
|
834
463
|
flags: snapshot.flags,
|
|
835
|
-
byKey: new Map(snapshot.flags.map((f) => [f.key, f]))
|
|
464
|
+
byKey: new Map(snapshot.flags.map((f) => [f.key, f])),
|
|
465
|
+
segmentsByKey: new Map((snapshot.segments ?? []).map((s) => [s.key, s]))
|
|
836
466
|
};
|
|
837
467
|
return Effect.asVoid(Deferred.succeed(firstSnapshot, void 0));
|
|
838
468
|
});
|
|
469
|
+
const warnedRemoteSegments = /* @__PURE__ */ new Set();
|
|
470
|
+
const memberOf = (state, ctx) => (segmentKey) => {
|
|
471
|
+
const segment = state.segmentsByKey.get(segmentKey);
|
|
472
|
+
if (segment === void 0) return false;
|
|
473
|
+
if (matchSegment(segment, ctx)) return true;
|
|
474
|
+
if (segment.listMode === "remote" && !warnedRemoteSegments.has(segmentKey)) {
|
|
475
|
+
warnedRemoteSegments.add(segmentKey);
|
|
476
|
+
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.`);
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
};
|
|
480
|
+
const evaluate = (key, defaultValue, context) => Effect.sync(() => {
|
|
481
|
+
const state = current;
|
|
482
|
+
if (state === void 0) return errorDetails(key, defaultValue, "PROVIDER_NOT_READY", "No flag snapshot loaded yet");
|
|
483
|
+
const flag = state.byKey.get(key);
|
|
484
|
+
if (flag === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
|
|
485
|
+
const ctx = mergedContext(context);
|
|
486
|
+
const resolution = evaluateFlag(flag, ctx, memberOf(state, ctx));
|
|
487
|
+
record(key, resolution);
|
|
488
|
+
return resolutionDetails(key, resolution, defaultValue);
|
|
489
|
+
});
|
|
490
|
+
const evaluateAll = (context) => Effect.sync(() => {
|
|
491
|
+
const state = current;
|
|
492
|
+
if (state === void 0) return [];
|
|
493
|
+
const ctx = mergedContext(context);
|
|
494
|
+
const isMemberOf = memberOf(state, ctx);
|
|
495
|
+
return state.flags.map((flag) => {
|
|
496
|
+
const resolution = evaluateFlag(flag, ctx, isMemberOf);
|
|
497
|
+
record(flag.key, resolution);
|
|
498
|
+
return resolutionDetails(flag.key, resolution, null);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
return {
|
|
502
|
+
adopt,
|
|
503
|
+
epoch: () => epoch,
|
|
504
|
+
version: () => current?.version,
|
|
505
|
+
impl: {
|
|
506
|
+
ready: Deferred.await(firstSnapshot),
|
|
507
|
+
evaluate,
|
|
508
|
+
evaluateAll
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
/**
|
|
513
|
+
* The network source (ADR-0012): bootstrap + safety-net poll over
|
|
514
|
+
* `GET /sync/v1/flags`, plus a live WebSocket stream. Forks its fibers into the
|
|
515
|
+
* caller's scope and feeds `store.adopt`.
|
|
516
|
+
*/
|
|
517
|
+
const startNetworkSource = (options, http, store, timeout) => Effect.gen(function* () {
|
|
518
|
+
const transport = options.sync?.transport ?? "ws";
|
|
519
|
+
const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
839
520
|
const fetchSnapshot = Effect.gen(function* () {
|
|
840
521
|
const response = yield* http.execute(HttpClientRequest.get("/sync/v1/flags"));
|
|
841
522
|
return yield* HttpClientResponse.matchStatus(response, {
|
|
@@ -844,17 +525,17 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
|
|
|
844
525
|
});
|
|
845
526
|
}).pipe(Effect.timeout(timeout));
|
|
846
527
|
const pollOnce = Effect.gen(function* () {
|
|
847
|
-
const before = epoch;
|
|
528
|
+
const before = store.epoch();
|
|
848
529
|
const snapshot = yield* fetchSnapshot;
|
|
849
|
-
if (epoch !== before) return;
|
|
850
|
-
if (
|
|
851
|
-
yield* adopt(snapshot);
|
|
530
|
+
if (store.epoch() !== before) return;
|
|
531
|
+
if (store.version() === snapshot.version) return;
|
|
532
|
+
yield* store.adopt(snapshot);
|
|
852
533
|
});
|
|
853
534
|
const ticker = Effect.gen(function* () {
|
|
854
535
|
let delay = 250;
|
|
855
|
-
while (
|
|
536
|
+
while (store.version() === void 0) {
|
|
856
537
|
yield* pollOnce.pipe(Effect.ignore);
|
|
857
|
-
if (
|
|
538
|
+
if (store.version() !== void 0) break;
|
|
858
539
|
yield* Effect.sleep(Duration.millis(delay));
|
|
859
540
|
delay = Math.min(delay * 2, 5e3);
|
|
860
541
|
}
|
|
@@ -863,20 +544,25 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
|
|
|
863
544
|
const decodeMessage = Schema.decodeUnknownEffect(SyncServerMessage);
|
|
864
545
|
const handleMessage = (raw) => Effect.gen(function* () {
|
|
865
546
|
const message = yield* decodeMessage(JSON.parse(String(raw)));
|
|
866
|
-
if (
|
|
867
|
-
yield* adopt(message);
|
|
547
|
+
if (store.version() === message.version) return;
|
|
548
|
+
yield* store.adopt(message);
|
|
868
549
|
}).pipe(Effect.ignore);
|
|
869
|
-
const streamUrl = toStreamUrl(options.endpoint);
|
|
550
|
+
const streamUrl = toStreamUrl(options.endpoint ?? "");
|
|
870
551
|
let consecutiveFailures = 0;
|
|
871
552
|
const streamLoop = Effect.gen(function* () {
|
|
872
553
|
const socket = yield* Socket.makeWebSocket(streamUrl, {
|
|
873
|
-
protocols: [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey}`],
|
|
554
|
+
protocols: [SYNC_PROTOCOL, `${SYNC_BEARER_PROTOCOL_PREFIX}${options.sdkKey ?? ""}`],
|
|
874
555
|
openTimeout: timeout
|
|
875
556
|
});
|
|
876
557
|
yield* Effect.forever(Effect.gen(function* () {
|
|
877
|
-
yield*
|
|
558
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
559
|
+
const pull = yield* Socket.readerString(socket);
|
|
878
560
|
consecutiveFailures = 0;
|
|
879
|
-
|
|
561
|
+
while (true) {
|
|
562
|
+
const messages = yield* pull;
|
|
563
|
+
for (const message of messages) yield* handleMessage(message);
|
|
564
|
+
}
|
|
565
|
+
})).pipe(Effect.ignore);
|
|
880
566
|
consecutiveFailures++;
|
|
881
567
|
const base = Math.min(3e4, 1e3 * 2 ** Math.min(consecutiveFailures - 1, 5));
|
|
882
568
|
yield* Effect.sleep(Duration.millis(base * (.5 + Math.random() * .5)));
|
|
@@ -884,32 +570,114 @@ const makeLocal = (options, http, mergedContext, timeout) => Effect.gen(function
|
|
|
884
570
|
});
|
|
885
571
|
yield* Effect.forkScoped(ticker);
|
|
886
572
|
if (transport === "ws") yield* Effect.forkScoped(streamLoop);
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
573
|
+
});
|
|
574
|
+
/**
|
|
575
|
+
* The file source (ADR-0013): read a `FlagSnapshot` from disk, watch it for
|
|
576
|
+
* edits, and adopt on change. The envelope keys a `pull`-generated file carries
|
|
577
|
+
* (`$schema`, `toggld`) are ignored by the snapshot decoder. No network, key, or
|
|
578
|
+
* stream — purely local. Forks its fibers into the caller's scope.
|
|
579
|
+
*/
|
|
580
|
+
const startFileSource = (path, store, options) => Effect.gen(function* () {
|
|
581
|
+
const pollIntervalMs = options.sync?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
582
|
+
const decodeSnapshot = Schema.decodeUnknownEffect(FlagSnapshot);
|
|
583
|
+
let lastError;
|
|
584
|
+
const readOnce = Effect.gen(function* () {
|
|
585
|
+
const json = yield* Effect.tryPromise(async () => JSON.parse(await readFile(path, "utf8")));
|
|
586
|
+
const snapshot = yield* decodeSnapshot(json);
|
|
587
|
+
lastError = void 0;
|
|
588
|
+
if (store.version() === snapshot.version) return;
|
|
589
|
+
yield* store.adopt(snapshot);
|
|
590
|
+
}).pipe(Effect.catch((error) => Effect.sync(() => {
|
|
591
|
+
const message = describe(error);
|
|
592
|
+
if (message === lastError) return;
|
|
593
|
+
lastError = message;
|
|
594
|
+
console.warn(`[toggld] Could not load flags file '${path}': ${message}`);
|
|
595
|
+
})));
|
|
596
|
+
const ticker = Effect.gen(function* () {
|
|
597
|
+
let delay = 100;
|
|
598
|
+
while (store.version() === void 0) {
|
|
599
|
+
yield* readOnce;
|
|
600
|
+
if (store.version() !== void 0) break;
|
|
601
|
+
yield* Effect.sleep(Duration.millis(delay));
|
|
602
|
+
delay = Math.min(delay * 2, 2e3);
|
|
603
|
+
}
|
|
604
|
+
yield* Effect.forever(Effect.sleep(Duration.millis(pollIntervalMs)).pipe(Effect.andThen(readOnce)));
|
|
892
605
|
});
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
606
|
+
const dir = dirname(path);
|
|
607
|
+
const base = basename(path);
|
|
608
|
+
const controller = new AbortController();
|
|
609
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()));
|
|
610
|
+
const watchLoop = Effect.gen(function* () {
|
|
611
|
+
while (!controller.signal.aborted) {
|
|
612
|
+
const iterator = yield* Effect.sync(() => {
|
|
613
|
+
try {
|
|
614
|
+
return watch(dir, { signal: controller.signal })[Symbol.asyncIterator]();
|
|
615
|
+
} catch {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
if (iterator === void 0) {
|
|
620
|
+
yield* Effect.sleep(Duration.millis(1e3));
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
let live = true;
|
|
624
|
+
while (live) {
|
|
625
|
+
const next = yield* Effect.tryPromise(() => iterator.next()).pipe(Effect.catch(() => Effect.succeed({
|
|
626
|
+
done: true,
|
|
627
|
+
value: void 0
|
|
628
|
+
})));
|
|
629
|
+
if (next.done) {
|
|
630
|
+
live = false;
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
const filename = next.value?.filename;
|
|
634
|
+
if (filename === base || filename === null || filename === void 0) yield* readOnce;
|
|
635
|
+
}
|
|
636
|
+
if (controller.signal.aborted) break;
|
|
637
|
+
yield* Effect.sleep(Duration.millis(500));
|
|
638
|
+
}
|
|
897
639
|
});
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
640
|
+
yield* Effect.forkScoped(watchLoop, { startImmediately: true });
|
|
641
|
+
yield* Effect.forkScoped(ticker);
|
|
642
|
+
});
|
|
643
|
+
/**
|
|
644
|
+
* Resolve a local flag-file path, or `undefined` for network sync. Highest
|
|
645
|
+
* precedence first: the `TOGGLD_FLAGS_FILE` env var (so a deployed app can be
|
|
646
|
+
* redirected at a file with no code change), then an explicit `sync.file`, then
|
|
647
|
+
* `./toggld.flags.json` when `sync.file` is `true`.
|
|
648
|
+
*/
|
|
649
|
+
const resolveFilePath = (options) => {
|
|
650
|
+
const env = process.env.TOGGLD_FLAGS_FILE;
|
|
651
|
+
if (typeof env === "string" && env.length > 0) return env;
|
|
652
|
+
const file = options.sync?.file;
|
|
653
|
+
if (typeof file === "string" && file.length > 0) return file;
|
|
654
|
+
if (file === true) return DEFAULT_FLAGS_FILE;
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* Assemble the local-mode evaluation surface, choosing the file source when a
|
|
658
|
+
* path resolves (env var or `sync.file`) and the network source otherwise.
|
|
659
|
+
*/
|
|
660
|
+
const makeLocal = (options, http, mergedContext, timeout, record) => Effect.gen(function* () {
|
|
661
|
+
const store = yield* makeSnapshotStore(mergedContext, record);
|
|
662
|
+
const filePath = resolveFilePath(options);
|
|
663
|
+
if (filePath !== void 0) yield* startFileSource(filePath, store, options);
|
|
664
|
+
else yield* startNetworkSource(options, http, store, timeout);
|
|
665
|
+
return store.impl;
|
|
903
666
|
});
|
|
904
667
|
const make = (options) => Effect.gen(function* () {
|
|
905
668
|
const base = yield* HttpClient.HttpClient;
|
|
906
669
|
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)));
|
|
670
|
+
const http = base.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(options.endpoint ?? "")), HttpClient.mapRequest(HttpClientRequest.bearerToken(options.sdkKey ?? "")));
|
|
908
671
|
const mergedContext = (context) => ({
|
|
909
672
|
...options.defaultContext,
|
|
910
673
|
...context
|
|
911
674
|
});
|
|
912
|
-
const
|
|
675
|
+
const local = options.mode === "local" || resolveFilePath(options) !== void 0;
|
|
676
|
+
const telemetry = local && resolveFilePath(options) === void 0 && options.telemetry !== false ? yield* startTelemetry(http, options) : {
|
|
677
|
+
record: (_key, _resolution) => {},
|
|
678
|
+
flush: Effect.void
|
|
679
|
+
};
|
|
680
|
+
const { evaluate, evaluateAll, ready } = local ? yield* makeLocal(options, http, mergedContext, timeout, telemetry.record) : makeRemote(http, mergedContext, timeout);
|
|
913
681
|
/** Resolve, then enforce the requested value type (else `TYPE_MISMATCH`). */
|
|
914
682
|
const typed = (key, defaultValue, context, type, guard) => Effect.map(evaluate(key, defaultValue, context), (d) => {
|
|
915
683
|
if (d.errorCode !== void 0) return d;
|
|
@@ -919,6 +687,7 @@ const make = (options) => Effect.gen(function* () {
|
|
|
919
687
|
const valueOf = (details) => Effect.map(details, (d) => d.value);
|
|
920
688
|
return {
|
|
921
689
|
ready,
|
|
690
|
+
flushTelemetry: telemetry.flush,
|
|
922
691
|
evaluate,
|
|
923
692
|
evaluateAll,
|
|
924
693
|
getBooleanDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "boolean", isBoolean),
|
|
@@ -934,7 +703,7 @@ const make = (options) => Effect.gen(function* () {
|
|
|
934
703
|
/** The HttpClient layer, optionally backed by a caller-supplied `fetch`. */
|
|
935
704
|
const httpLayer = (fetchImpl) => fetchImpl ? FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImpl))) : FetchHttpClient.layer;
|
|
936
705
|
/** The WebSocket constructor for the sync stream, optionally caller-supplied. */
|
|
937
|
-
const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructor, (url,
|
|
706
|
+
const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructor, (url, options) => new ctor(url, typeof options === "object" && !Array.isArray(options) ? options.protocols : options)) : Socket.layerWebSocketConstructorGlobal;
|
|
938
707
|
/**
|
|
939
708
|
* A self-contained `Layer` providing {@link ToggldClient} — it wires its own
|
|
940
709
|
* HTTP and WebSocket backends, so Effect users need only provide this one
|
|
@@ -942,5 +711,4 @@ const webSocketLayer = (ctor) => ctor ? Layer.succeed(Socket.WebSocketConstructo
|
|
|
942
711
|
* closing it (e.g. `ManagedRuntime.dispose`) stops sync and closes the stream.
|
|
943
712
|
*/
|
|
944
713
|
const layer = (options) => Layer.effect(ToggldClient, make(options)).pipe(Layer.provide(httpLayer(options.fetch)), Layer.provide(webSocketLayer(options.sync?.webSocketConstructor)));
|
|
945
|
-
//#endregion
|
|
946
714
|
export { layer as n, ToggldClient as t };
|