@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 CHANGED
@@ -16,11 +16,14 @@ Two evaluation modes:
16
16
  evaluation" over the OFREP endpoints).
17
17
  - **Local** (`mode: "local"`) — recommended for long-lived servers. Flag
18
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
19
+ stream where supported, with polling fallback) and every evaluation runs **in-process** on
20
+ the bundled engine without a server request per evaluation. Workers currently
21
+ uses polling for definition updates. Cache freshness, polling intervals, and
22
+ network conditions affect when changed definitions arrive. Requires
22
23
  a secret SDK key (`ffk_…`) — public browser tokens cannot read rulesets. See
23
- [ADR-0012](../../docs/adr/0012-definition-sync-and-local-evaluation.md).
24
+ [ADR-0012](../../docs/adr/0012-definition-sync-and-local-evaluation.md). Local
25
+ mode can also read its snapshot from a **file** instead of the network — see
26
+ [Local flag files](#local-flag-files-offline) below.
24
27
 
25
28
  ## Install
26
29
 
@@ -89,16 +92,22 @@ scope.
89
92
  | `sync.transport` | `"ws" \| "poll"` | `"ws"` | Live stream (with poll safety net) vs polling only. |
90
93
  | `sync.pollIntervalMs` | `number` | `30000` | Snapshot poll cadence; cold start retries faster. |
91
94
  | `sync.readyTimeoutMs` | `number` | `5000` | How long `create` waits for the first snapshot. |
95
+ | `sync.file` | `string \| boolean` | — | Read the snapshot from a local file (offline); `true` ⇒ `./toggld.flags.json`. |
92
96
  | `defaultContext` | `EvaluationContext` | `{}` | Merged under per-call context (per-call attributes win). |
93
97
  | `timeoutMs` | `number` | `5000` | Per-request timeout. |
94
98
  | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch (tests, proxies, instrumentation). |
95
99
 
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.
100
+ In local mode, `create` waits for the first snapshot or `sync.readyTimeoutMs`.
101
+ Until a snapshot arrives, evaluations return your defaults with
102
+ `errorCode: "PROVIDER_NOT_READY"`, and sync keeps retrying. `client.ready()` waits
103
+ for the first snapshot without a timeout.
104
+
105
+ Changes on the connected Node instance normally arrive through WebSocket pushes.
106
+ The SDK also polls every 30 seconds, even while the stream stays healthy.
107
+ Polling recovers writes from other instances and announcements that the bounded
108
+ server buffer drops. With healthy requests, the conservative bound is
109
+ `sync.pollIntervalMs + 2 * timeoutMs`, plus scheduling delays, or 40 seconds by default.
110
+ The [Node sync guide](../../docs/node-sync.md) defines the assumptions and deployment requirements.
102
111
 
103
112
  ### How fast is local mode?
104
113
 
@@ -110,6 +119,46 @@ sync OFF (remote OFREP) ~11,600 evals/s p50 2.4 ms
110
119
  sync ON (local engine) ~1,090,000 evals/s p50 27 µs (0.6 µs single-caller)
111
120
  ```
112
121
 
122
+ ## Local flag files (offline)
123
+
124
+ For local development, CI, or offline runs, a client can evaluate against a
125
+ **file** instead of syncing over the network — same engine, same results, no
126
+ flag API required. Generate the file with the bundled CLI:
127
+
128
+ ```bash
129
+ # Fetch the environment's current flags into ./toggld.flags.json.
130
+ # Prompts for the key if you omit --sdk-key (or set TOGGLD_SDK_KEY).
131
+ npx @toggld/node pull --sdk-key ffk_… --api https://your-flag-api.example.com
132
+ ```
133
+
134
+ Then point a client at it — no `sdkKey` or `endpoint` needed:
135
+
136
+ ```ts
137
+ const client = await ToggldClient.create({
138
+ mode: "local",
139
+ sync: { file: "./toggld.flags.json" }, // or `file: true` for that default path
140
+ })
141
+ ```
142
+
143
+ The file is a `FlagSnapshot` (the exact wire snapshot the SDK syncs) wrapped in a
144
+ small provenance envelope, so it is human-readable, diffable, and editable —
145
+ flip an `enabled`, nudge a rollout, commit a fixture. **Edits are picked up
146
+ live** (the file is watched); no restart.
147
+
148
+ You can also redirect *any* client to a file with **no code change** by setting
149
+ `TOGGLD_FLAGS_FILE` — it overrides the network source even under the default
150
+ remote mode:
151
+
152
+ ```bash
153
+ TOGGLD_FLAGS_FILE=./toggld.flags.json node server.js
154
+ ```
155
+
156
+ `pull` needs a secret key (`ffk_…`); it is one-shot and idempotent (an unchanged
157
+ version is not rewritten — pass `--force` to override). See
158
+ [ADR-0013](../../docs/adr/0013-local-flag-files-and-pull-cli.md). Remote-segment
159
+ membership (ADR-0006) is not yet available offline; a file-backed client warns
160
+ once and treats a remote segment's non-rule-matched subjects as non-members.
161
+
113
162
  ## Error semantics
114
163
 
115
164
  Evaluation never throws. On a network failure, timeout, unknown flag, server
@@ -117,3 +166,53 @@ error, or a value whose type doesn't match the requested getter, the call
117
166
  resolves to your **default value**. The `*Details` methods carry `reason:
118
167
  "ERROR"` and an OpenFeature `errorCode` (`FLAG_NOT_FOUND`, `TYPE_MISMATCH`,
119
168
  `TARGETING_KEY_MISSING`, `PARSE_ERROR`, `GENERAL`, …) describing what happened.
169
+
170
+
171
+ ## Evaluation telemetry
172
+
173
+ In network-synced local mode, the SDK collects evaluation counts automatically.
174
+ It groups counts by flag, variant, result reason, and UTC minute. The SDK sends
175
+ batches to `POST /v1/telemetry` every 30 seconds and during `close()`.
176
+ Evaluation calls do not wait for these requests. Both SDK APIs use this reporter.
177
+
178
+ The SDK reports engine results for known flags, including engine errors.
179
+ It excludes unknown flags, calls before readiness, and errors from typed getters.
180
+ Each bulk evaluation counts each flag once. The SDK sends no user identities,
181
+ evaluation context, values, or metadata. Remote mode relies on server collection.
182
+ File-based mode sends no telemetry, even when credentials are present.
183
+
184
+ ```ts
185
+ const client = await ToggldClient.create({
186
+ endpoint,
187
+ sdkKey,
188
+ mode: "local",
189
+ telemetry: { flushIntervalMs: 30_000 },
190
+ })
191
+
192
+ await client.getBooleanValue("checkout", false, { targetingKey: "user-123" })
193
+ await client.flushTelemetry()
194
+ await client.close()
195
+ ```
196
+
197
+ Set `telemetry: false` to disable reporting. Use `flushTelemetry()` to send counts
198
+ before the next interval. Effect users can yield `client.flushTelemetry` and close
199
+ the layer scope to flush remaining counts. A flush failure leaves the batch
200
+ queued for the next attempt. Retries keep the same batch ID to prevent duplicate
201
+ counts after a lost response.
202
+
203
+ The SDK holds at most 10,000 active groups and 10,000 groups awaiting retry.
204
+ If the active buffer fills, the SDK drops new groups. Each request contains at
205
+ most 1,000 groups. Each count stops at 1,000,000,000. Keys and variants longer
206
+ than 256 characters do not produce telemetry. Process crashes can lose buffered
207
+ counts. Closing the client limits its final telemetry attempt to `timeoutMs`,
208
+ which defaults to five seconds. Counts remain approximate.
209
+
210
+
211
+ The integration suite can use Postgres for the complete SDK-to-storage path.
212
+ It creates tenants and flags, so use a disposable database. From the repository
213
+ root, set `DATABASE_URL` to that database and run the command below. The test
214
+ server applies migrations before it starts.
215
+
216
+ ```bash
217
+ TOGGLD_TEST_POSTGRES=1 DATABASE_URL=postgres://postgres:postgres@localhost:55439/toggld pnpm --filter @toggld/node exec vitest run test/telemetry.test.ts
218
+ ```
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.mjs ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ import { t as FlagSnapshot } from "./src-B_M-Oix8.mjs";
3
+ import { Effect, Schema } from "effect";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ // src/internal/cli.ts
6
+ /**
7
+ * The `toggld` CLI (ADR-0013). Today it has one command:
8
+ *
9
+ * toggld pull [--sdk-key <key>] [--api <url>] [--out <path>] [--env-name <name>] [--force]
10
+ *
11
+ * `pull` does exactly what a local-mode SDK does on bootstrap — `GET
12
+ * /sync/v1/flags` with the SDK key — then writes the validated `FlagSnapshot` to
13
+ * disk as a local flag file (the wire snapshot plus a small provenance
14
+ * envelope). Point a file-backed client at it for offline local evaluation.
15
+ *
16
+ * This module is side-effect-free on import: {@link runPull} performs the fetch
17
+ * and write, and {@link runMain} parses argv and prints. The executable entry
18
+ * (`../cli.ts`) is a one-line wrapper. Keeping the logic here lets tests call it
19
+ * directly instead of spawning a process.
20
+ */
21
+ const SCHEMA_URL = "https://toggld.dev/schema/local-flags.v1.json";
22
+ const DEFAULT_OUT = "./toggld.flags.json";
23
+ const DEFAULT_ENDPOINT = "http://localhost:3000";
24
+ /** A user-facing failure: printed as a clean message, never a stack trace. */
25
+ var CliError = class extends Error {
26
+ /** An optional follow-up line with remediation guidance. */
27
+ hint;
28
+ constructor(message, hint) {
29
+ super(message);
30
+ this.name = "CliError";
31
+ this.hint = hint;
32
+ }
33
+ };
34
+ const decodeSnapshot = Schema.decodeUnknownEffect(FlagSnapshot);
35
+ const normalizeEndpoint = (endpoint) => endpoint.replace(/\/+$/, "");
36
+ /** The version already on disk at `out`, if it is a readable local flag file. */
37
+ const existingVersion = async (out) => {
38
+ try {
39
+ const parsed = JSON.parse(await readFile(out, "utf8"));
40
+ return typeof parsed.version === "string" ? parsed.version : void 0;
41
+ } catch {
42
+ return;
43
+ }
44
+ };
45
+ /**
46
+ * Fetch the environment's current snapshot for `sdkKey` and write it to `out` as
47
+ * a local flag file. Idempotent: an unchanged version is a no-op unless `force`.
48
+ */
49
+ const runPull = async (options) => {
50
+ const fetchImpl = options.fetch ?? globalThis.fetch;
51
+ const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
52
+ const endpoint = normalizeEndpoint(options.endpoint);
53
+ const url = `${endpoint}/sync/v1/flags`;
54
+ let response;
55
+ try {
56
+ response = await fetchImpl(url, { headers: {
57
+ authorization: `Bearer ${options.sdkKey}`,
58
+ accept: "application/json"
59
+ } });
60
+ } catch (error) {
61
+ throw new CliError(`Could not reach the flag API at ${endpoint}: ${describe(error)}`, "Check --api / TOGGLD_FLAG_API_URL and that the server is running.");
62
+ }
63
+ if (response.status === 401) throw new CliError("The flag API rejected the SDK key (401 Unauthorized).", "Check the key.");
64
+ if (response.status === 403) throw new CliError("This key cannot read flag definitions (403 Forbidden).", "`pull` needs a secret SDK key (ffk_…); public browser tokens (ffp_…) can't read definitions.");
65
+ if (!response.ok) throw new CliError(`The flag API returned ${response.status} ${response.statusText}.`);
66
+ const body = await response.json().catch(() => {
67
+ throw new CliError("The flag API response was not valid JSON.");
68
+ });
69
+ const snapshot = await Effect.runPromise(decodeSnapshot(body)).catch(() => {
70
+ throw new CliError("The flag API response did not match the expected snapshot format.");
71
+ });
72
+ if (!options.force && await existingVersion(options.out) === snapshot.version) return {
73
+ out: options.out,
74
+ version: snapshot.version,
75
+ flagCount: snapshot.flags.length,
76
+ segmentCount: snapshot.segments?.length ?? 0,
77
+ unchanged: true
78
+ };
79
+ const envelope = {
80
+ $schema: SCHEMA_URL,
81
+ toggld: {
82
+ ...options.envName ? { environment: options.envName } : {},
83
+ apiBase: endpoint,
84
+ generatedAt: now(),
85
+ generatedBy: "toggld pull"
86
+ },
87
+ version: snapshot.version,
88
+ flags: snapshot.flags,
89
+ ...snapshot.segments ? { segments: snapshot.segments } : {}
90
+ };
91
+ await writeFile(options.out, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
92
+ return {
93
+ out: options.out,
94
+ version: snapshot.version,
95
+ flagCount: snapshot.flags.length,
96
+ segmentCount: snapshot.segments?.length ?? 0,
97
+ unchanged: false
98
+ };
99
+ };
100
+ const describe = (e) => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" ? e.message : String(e);
101
+ const USAGE = `toggld — Toggld feature-flag CLI
102
+
103
+ Usage:
104
+ toggld pull [options] Fetch an environment's flags into a local file
105
+
106
+ Options for "pull":
107
+ -k, --sdk-key <key> Secret SDK key (ffk_…). Falls back to TOGGLD_SDK_KEY,
108
+ then prompts. Required.
109
+ -a, --api <url> Flag API base URL. Falls back to TOGGLD_FLAG_API_URL,
110
+ then ${DEFAULT_ENDPOINT}.
111
+ -o, --out <path> Output file. Default ${DEFAULT_OUT}.
112
+ --env-name <name> Label recorded in the file's provenance envelope.
113
+ -f, --force Rewrite even if the file already has this version.
114
+ -h, --help Show this help.
115
+
116
+ Point a local-mode client at the file with TOGGLD_FLAGS_FILE or
117
+ { sync: { file: "<path>" } }.`;
118
+ const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
119
+ "force",
120
+ "f",
121
+ "help",
122
+ "h"
123
+ ]);
124
+ const ALIASES = {
125
+ k: "sdk-key",
126
+ a: "api",
127
+ o: "out",
128
+ f: "force",
129
+ h: "help"
130
+ };
131
+ const parseArgs = (argv) => {
132
+ const flags = {};
133
+ let command;
134
+ for (let i = 0; i < argv.length; i++) {
135
+ const arg = argv[i];
136
+ if (!arg.startsWith("-")) {
137
+ command ??= arg;
138
+ continue;
139
+ }
140
+ const eq = arg.indexOf("=");
141
+ const rawName = (eq === -1 ? arg : arg.slice(0, eq)).replace(/^-+/, "");
142
+ const name = ALIASES[rawName] ?? rawName;
143
+ const canonical = ALIASES[rawName] ?? rawName;
144
+ if (BOOLEAN_FLAGS.has(rawName) || BOOLEAN_FLAGS.has(canonical)) {
145
+ flags[name] = true;
146
+ continue;
147
+ }
148
+ if (eq !== -1) flags[name] = arg.slice(eq + 1);
149
+ else flags[name] = argv[++i] ?? "";
150
+ }
151
+ return {
152
+ command,
153
+ flags
154
+ };
155
+ };
156
+ /** Read a secret without echoing it, from an interactive terminal. */
157
+ const promptSecret = async (label) => {
158
+ if (!process.stdin.isTTY) throw new CliError("No SDK key provided.", "Pass --sdk-key, set TOGGLD_SDK_KEY, or run in an interactive terminal.");
159
+ const { createInterface } = await import("node:readline");
160
+ const rl = createInterface({
161
+ input: process.stdin,
162
+ output: process.stdout,
163
+ terminal: true
164
+ });
165
+ const internals = rl;
166
+ const write = internals.output.write.bind(internals.output);
167
+ let muted = false;
168
+ internals._writeToOutput = (s) => {
169
+ if (!muted || s.includes(label)) write(s);
170
+ };
171
+ try {
172
+ const answer = await new Promise((resolve) => {
173
+ rl.question(label, resolve);
174
+ muted = true;
175
+ });
176
+ write("\n");
177
+ return answer.trim();
178
+ } finally {
179
+ rl.close();
180
+ }
181
+ };
182
+ /**
183
+ * Parse argv, run the requested command, and return a process exit code. Prints
184
+ * results to stdout and {@link CliError}s to stderr; unexpected errors rethrow.
185
+ */
186
+ const runMain = async (argv) => {
187
+ const { command, flags } = parseArgs(argv);
188
+ if (flags["help"] || command === void 0 || command === "help") {
189
+ process.stdout.write(`${USAGE}\n`);
190
+ return command === void 0 && !flags["help"] ? 1 : 0;
191
+ }
192
+ if (command !== "pull") {
193
+ process.stderr.write(`Unknown command "${command}".\n\n${USAGE}\n`);
194
+ return 1;
195
+ }
196
+ try {
197
+ const endpoint = asString(flags["api"]) ?? process.env.TOGGLD_FLAG_API_URL ?? DEFAULT_ENDPOINT;
198
+ const out = asString(flags["out"]) ?? DEFAULT_OUT;
199
+ const envName = asString(flags["env-name"]);
200
+ const sdkKey = asString(flags["sdk-key"]) ?? (process.env.TOGGLD_SDK_KEY || void 0) ?? await promptSecret("Toggld SDK key: ");
201
+ if (!sdkKey) throw new CliError("No SDK key provided.");
202
+ const result = await runPull({
203
+ sdkKey,
204
+ endpoint,
205
+ out,
206
+ force: Boolean(flags["force"]),
207
+ ...envName ? { envName } : {}
208
+ });
209
+ if (result.unchanged) process.stdout.write(`✓ ${result.out} already up to date (version ${short(result.version)}).\n`);
210
+ else {
211
+ const segs = result.segmentCount > 0 ? `, ${result.segmentCount} segments` : "";
212
+ process.stdout.write(`✓ Wrote ${result.out} — ${result.flagCount} flags${segs} (version ${short(result.version)}).\n`);
213
+ }
214
+ return 0;
215
+ } catch (error) {
216
+ if (error instanceof CliError) {
217
+ process.stderr.write(`✗ ${error.message}\n`);
218
+ if (error.hint) process.stderr.write(` ${error.hint}\n`);
219
+ return 1;
220
+ }
221
+ throw error;
222
+ }
223
+ };
224
+ const asString = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
225
+ const short = (version) => version.length > 12 ? `${version.slice(0, 12)}…` : version;
226
+ // src/cli.ts
227
+ /**
228
+ * The `toggld` executable. All logic lives in `./internal/cli.ts` (so tests can
229
+ * call it without spawning a process); this is just the entry that runs it.
230
+ */
231
+ runMain(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
232
+ console.error(error);
233
+ process.exit(1);
234
+ });
235
+ export {};