@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.
@@ -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 FlagMetadata, i as EvaluationContext, n as ToggldClientOptions, o as FlagValue, r as ErrorCode, s as Reason, t as EvaluationDetails } from "./types-RmWNnA7-.mjs";
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 in one request (OFREP bulk). */
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 backend, so Effect users need only provide this one layer.
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-zMjaOru1.mjs";
1
+ import { n as layer, t as ToggldClient } from "./core-DBVGNtur.mjs";
2
2
  export { ToggldClient, layer };