@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 +44 -1
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +237 -0
- package/dist/{core-Bx0PVoEm.mjs → core-DBVGNtur.mjs} +192 -494
- package/dist/effect.d.mts +1 -1
- package/dist/effect.mjs +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +3 -5
- package/dist/src-CCPvsrli.mjs +571 -0
- package/dist/{types-DItF1mIU.d.mts → types-CPtRV5-5.d.mts} +31 -5
- package/package.json +4 -1
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 {};
|