@toggld/node 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,9 @@ Two evaluation modes:
20
20
  the bundled engine: microsecond latency, zero server requests per
21
21
  evaluation, and changes pushed to your process within milliseconds. Requires
22
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).
23
+ [ADR-0012](../../docs/adr/0012-definition-sync-and-local-evaluation.md). Local
24
+ mode can also read its snapshot from a **file** instead of the network — see
25
+ [Local flag files](#local-flag-files-offline) below.
24
26
 
25
27
  ## Install
26
28
 
@@ -89,6 +91,7 @@ scope.
89
91
  | `sync.transport` | `"ws" \| "poll"` | `"ws"` | Live stream (with poll safety net) vs polling only. |
90
92
  | `sync.pollIntervalMs` | `number` | `30000` | Snapshot poll cadence; cold start retries faster. |
91
93
  | `sync.readyTimeoutMs` | `number` | `5000` | How long `create` waits for the first snapshot. |
94
+ | `sync.file` | `string \| boolean` | — | Read the snapshot from a local file (offline); `true` ⇒ `./toggld.flags.json`. |
92
95
  | `defaultContext` | `EvaluationContext` | `{}` | Merged under per-call context (per-call attributes win). |
93
96
  | `timeoutMs` | `number` | `5000` | Per-request timeout. |
94
97
  | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch (tests, proxies, instrumentation). |
@@ -110,6 +113,46 @@ sync OFF (remote OFREP) ~11,600 evals/s p50 2.4 ms
110
113
  sync ON (local engine) ~1,090,000 evals/s p50 27 µs (0.6 µs single-caller)
111
114
  ```
112
115
 
116
+ ## Local flag files (offline)
117
+
118
+ For local development, CI, or offline runs, a client can evaluate against a
119
+ **file** instead of syncing over the network — same engine, same results, no
120
+ flag API required. Generate the file with the bundled CLI:
121
+
122
+ ```bash
123
+ # Fetch the environment's current flags into ./toggld.flags.json.
124
+ # Prompts for the key if you omit --sdk-key (or set TOGGLD_SDK_KEY).
125
+ npx @toggld/node pull --sdk-key ffk_… --api https://your-flag-api.example.com
126
+ ```
127
+
128
+ Then point a client at it — no `sdkKey` or `endpoint` needed:
129
+
130
+ ```ts
131
+ const client = await ToggldClient.create({
132
+ mode: "local",
133
+ sync: { file: "./toggld.flags.json" }, // or `file: true` for that default path
134
+ })
135
+ ```
136
+
137
+ The file is a `FlagSnapshot` (the exact wire snapshot the SDK syncs) wrapped in a
138
+ small provenance envelope, so it is human-readable, diffable, and editable —
139
+ flip an `enabled`, nudge a rollout, commit a fixture. **Edits are picked up
140
+ live** (the file is watched); no restart.
141
+
142
+ You can also redirect *any* client to a file with **no code change** by setting
143
+ `TOGGLD_FLAGS_FILE` — it overrides the network source even under the default
144
+ remote mode:
145
+
146
+ ```bash
147
+ TOGGLD_FLAGS_FILE=./toggld.flags.json node server.js
148
+ ```
149
+
150
+ `pull` needs a secret key (`ffk_…`); it is one-shot and idempotent (an unchanged
151
+ version is not rewritten — pass `--force` to override). See
152
+ [ADR-0013](../../docs/adr/0013-local-flag-files-and-pull-cli.md). Remote-segment
153
+ membership (ADR-0006) is not yet available offline; a file-backed client warns
154
+ once and treats a remote segment's non-rule-matched subjects as non-members.
155
+
113
156
  ## Error semantics
114
157
 
115
158
  Evaluation never throws. On a network failure, timeout, unknown flag, server
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export { };
package/dist/cli.mjs ADDED
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ import { t as FlagSnapshot } from "./src-CCPvsrli.mjs";
3
+ import { Effect, Schema } from "effect";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ //#region 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 = 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
+ //#endregion
227
+ //#region src/cli.ts
228
+ /**
229
+ * The `toggld` executable. All logic lives in `./internal/cli.ts` (so tests can
230
+ * call it without spawning a process); this is just the entry that runs it.
231
+ */
232
+ runMain(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
233
+ console.error(error);
234
+ process.exit(1);
235
+ });
236
+ //#endregion
237
+ export {};