@toggld/node 0.1.0 → 0.2.0

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