@toggld/js 0.0.0-stage → 0.9.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/LICENSE +202 -0
- package/README.md +355 -2
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +243 -0
- package/dist/core-BQfyNOoT.mjs +921 -0
- package/dist/details-AiMdctXB.mjs +365 -0
- package/dist/effect.d.mts +17 -0
- package/dist/effect.mjs +75 -0
- package/dist/effectService-CGZnDaqS.d.mts +48 -0
- package/dist/effectService-DPD-fNCI.mjs +17 -0
- package/dist/index.d.mts +54 -0
- package/dist/index.mjs +127 -0
- package/dist/serverless-B_Vyg8Nd.mjs +259 -0
- package/dist/serverless-e__iCjpQ.d.mts +206 -0
- package/dist/serverless-effect.d.mts +51 -0
- package/dist/serverless-effect.mjs +121 -0
- package/dist/serverless.d.mts +3 -0
- package/dist/serverless.mjs +38 -0
- package/dist/types-BHn_vrQq.d.mts +211 -0
- package/dist/wire-CrkBeqnM.mjs +447 -0
- package/package.json +76 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { n as mapMaybe, t as makeCore } from "./core-BQfyNOoT.mjs";
|
|
2
|
+
// src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* `@toggld/js` — the default entry: a plain Promise API with **zero runtime
|
|
5
|
+
* dependencies**.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { ToggldClient } from "@toggld/js"
|
|
9
|
+
*
|
|
10
|
+
* // Sync mode, the default: a copy of the flag rules stays current in the
|
|
11
|
+
* // background and every evaluation is in-process — microseconds, zero server requests.
|
|
12
|
+
* // `create` waits for the first snapshot (up to `sync.readyTimeoutMs`).
|
|
13
|
+
* const client = await ToggldClient.create({ sdkKey, endpoint })
|
|
14
|
+
* const on = await client.getBooleanValue("new-checkout", false, { targetingKey: "user-123" })
|
|
15
|
+
* await client.close() // stops the sync, so a script can exit
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Pass `mode: "file"` to evaluate from a flag file on disk (`file`, default
|
|
19
|
+
* `./toggld.flags.json`), or `mode: "remote"` to send each evaluation to the
|
|
20
|
+
* flag API over OFREP — for public tokens. On serverless and edge runtimes
|
|
21
|
+
* (Cloudflare Workers, Vercel, AWS Lambda), use `@toggld/js/serverless`, the
|
|
22
|
+
* remote client that sends one bulk request per incoming request.
|
|
23
|
+
* Evaluation never throws: any failure resolves to the supplied default value,
|
|
24
|
+
* with the reason/error available via the `*Details` methods.
|
|
25
|
+
*
|
|
26
|
+
* For an Effect app, `@toggld/js/effect` exposes the same client as a
|
|
27
|
+
* service and `Layer` (it needs the optional `effect` peer dependency).
|
|
28
|
+
*/
|
|
29
|
+
/** Calls after `close()` reject, as they always have: the client is unusable then. */
|
|
30
|
+
const closedError = () => /* @__PURE__ */ new Error("[toggld] The client is closed.");
|
|
31
|
+
/**
|
|
32
|
+
* A Toggld client. Create one with {@link ToggldClient.create} and {@link
|
|
33
|
+
* ToggldClient.close} it on shutdown to stop background sync.
|
|
34
|
+
*/
|
|
35
|
+
var ToggldClient = class ToggldClient {
|
|
36
|
+
#core;
|
|
37
|
+
constructor(core) {
|
|
38
|
+
this.#core = core;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Create a client. In sync mode (the default) it resolves once the first
|
|
42
|
+
* flag snapshot has synced, or once `sync.readyTimeoutMs` (default 5 s) has
|
|
43
|
+
* elapsed. In that case it warns once, and the client serves caller defaults
|
|
44
|
+
* with `PROVIDER_NOT_READY` while sync keeps retrying in the background. In
|
|
45
|
+
* file mode it resolves after the first read of the file; in remote mode, at
|
|
46
|
+
* once.
|
|
47
|
+
*
|
|
48
|
+
* Rejects with a `TypeError` when a sync-mode client could never sync: no
|
|
49
|
+
* `sdkKey`, a public token (`ffp_…`), or no valid `endpoint`. Use a secret
|
|
50
|
+
* SDK key, or pass `mode: "remote"`. Also rejects options that no longer
|
|
51
|
+
* apply: `mode: "local"` (use `"sync"` or `"file"`), `sync.file` (use
|
|
52
|
+
* `mode: "file", file`), and `file` without `mode: "file"`.
|
|
53
|
+
*/
|
|
54
|
+
static async create(options) {
|
|
55
|
+
const core = makeCore(options);
|
|
56
|
+
await core.waitForReady();
|
|
57
|
+
return new ToggldClient(core);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolves once the client has flag data to evaluate with (immediately in
|
|
61
|
+
* remote mode). Useful if `create` resolved on the ready timeout and you
|
|
62
|
+
* want to await sync explicitly. It has no timeout of its own, and it never
|
|
63
|
+
* resolves while the key or endpoint is wrong. Rejects if the client is
|
|
64
|
+
* closed first.
|
|
65
|
+
*/
|
|
66
|
+
ready() {
|
|
67
|
+
if (this.#core.closed()) return Promise.reject(closedError());
|
|
68
|
+
return this.#core.ready();
|
|
69
|
+
}
|
|
70
|
+
/** Send buffered counts without failing evaluations. Failed windows remain queued. */
|
|
71
|
+
flushTelemetry() {
|
|
72
|
+
if (this.#core.closed()) return Promise.reject(closedError());
|
|
73
|
+
return this.#core.flushTelemetry();
|
|
74
|
+
}
|
|
75
|
+
/** Resolve a flag, returning full details with an untyped (JSON) value. */
|
|
76
|
+
evaluate(key, defaultValue, context) {
|
|
77
|
+
if (this.#core.closed()) return Promise.reject(closedError());
|
|
78
|
+
return Promise.resolve(this.#core.evaluate(key, defaultValue, context));
|
|
79
|
+
}
|
|
80
|
+
/** Resolve every flag for the environment (one request in remote mode; in-process in sync and file modes). */
|
|
81
|
+
evaluateAll(context) {
|
|
82
|
+
if (this.#core.closed()) return Promise.reject(closedError());
|
|
83
|
+
return Promise.resolve(this.#core.evaluateAll(context));
|
|
84
|
+
}
|
|
85
|
+
getBooleanValue(key, defaultValue, context) {
|
|
86
|
+
return this.#value(key, defaultValue, context, "boolean");
|
|
87
|
+
}
|
|
88
|
+
getBooleanDetails(key, defaultValue, context) {
|
|
89
|
+
return this.#details(key, defaultValue, context, "boolean");
|
|
90
|
+
}
|
|
91
|
+
getStringValue(key, defaultValue, context) {
|
|
92
|
+
return this.#value(key, defaultValue, context, "string");
|
|
93
|
+
}
|
|
94
|
+
getStringDetails(key, defaultValue, context) {
|
|
95
|
+
return this.#details(key, defaultValue, context, "string");
|
|
96
|
+
}
|
|
97
|
+
getNumberValue(key, defaultValue, context) {
|
|
98
|
+
return this.#value(key, defaultValue, context, "number");
|
|
99
|
+
}
|
|
100
|
+
getNumberDetails(key, defaultValue, context) {
|
|
101
|
+
return this.#details(key, defaultValue, context, "number");
|
|
102
|
+
}
|
|
103
|
+
getObjectValue(key, defaultValue, context) {
|
|
104
|
+
return this.#value(key, defaultValue, context, "object");
|
|
105
|
+
}
|
|
106
|
+
getObjectDetails(key, defaultValue, context) {
|
|
107
|
+
return this.#details(key, defaultValue, context, "object");
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Stop background sync, close the stream, and send remaining telemetry
|
|
111
|
+
* (bounded by `timeoutMs`). The client is unusable afterwards. A sync-mode
|
|
112
|
+
* client's stream and poll (and a file-mode client's watcher) keep the process alive until you call this, so a
|
|
113
|
+
* script must close its client to exit.
|
|
114
|
+
*/
|
|
115
|
+
close() {
|
|
116
|
+
return this.#core.close();
|
|
117
|
+
}
|
|
118
|
+
#details(key, defaultValue, context, type) {
|
|
119
|
+
if (this.#core.closed()) return Promise.reject(closedError());
|
|
120
|
+
return Promise.resolve(this.#core.typed(key, defaultValue, context, type));
|
|
121
|
+
}
|
|
122
|
+
#value(key, defaultValue, context, type) {
|
|
123
|
+
if (this.#core.closed()) return Promise.reject(closedError());
|
|
124
|
+
return Promise.resolve(mapMaybe(this.#core.typed(key, defaultValue, context, type), (d) => d.value));
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
export { ToggldClient };
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { c as isRecord, d as discard, f as makeSend, h as withTimeout, l as apiUrl, n as decodeOfrepBulk, p as readJson, r as decodeOfrepBulkFailure, u as describe } from "./wire-CrkBeqnM.mjs";
|
|
2
|
+
import { _ as wireErrorDetails, g as rejectedMessage, h as okDetails, i as syncCredentialProblem, m as codeFor, n as errorDetails, r as invalidEndpointMessage, t as enforceType, y as makeRequestTelemetry } from "./details-AiMdctXB.mjs";
|
|
3
|
+
// src/internal/cache.ts
|
|
4
|
+
const CACHE_KEY_PREFIX = "toggld:v1:";
|
|
5
|
+
/** JSON with object keys sorted at every level, so equal contexts give equal strings. */
|
|
6
|
+
const canonicalJson = (u) => JSON.stringify(sorted(u)) ?? "null";
|
|
7
|
+
const sorted = (u) => {
|
|
8
|
+
if (Array.isArray(u)) return u.map(sorted);
|
|
9
|
+
if (!isRecord(u)) return u;
|
|
10
|
+
const proto = Object.getPrototypeOf(u);
|
|
11
|
+
if (proto !== null && proto !== Object.prototype) return u;
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const key of Object.keys(u).sort()) out[key] = sorted(u[key]);
|
|
14
|
+
return out;
|
|
15
|
+
};
|
|
16
|
+
const hex = (bytes) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
/** SHA-256 (WebCrypto, available on Workers, Node.js 20+, Deno, and Bun) as lowercase hex. */
|
|
18
|
+
const sha256Hex = async (text) => hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
19
|
+
/** The cache key of one bulk result. `keyHash` is {@link sha256Hex} of the SDK key, never the key. */
|
|
20
|
+
const cacheKeyFor = async (endpoint, keyHash, canonicalContext) => CACHE_KEY_PREFIX + await sha256Hex(JSON.stringify([
|
|
21
|
+
endpoint,
|
|
22
|
+
keyHash,
|
|
23
|
+
canonicalContext
|
|
24
|
+
]));
|
|
25
|
+
/** A cached value, checked like a bulk response, or `undefined` if it does not have the shape. */
|
|
26
|
+
const decodeCached = (u) => {
|
|
27
|
+
if (!isRecord(u) || typeof u.storedAt !== "number" || !Number.isFinite(u.storedAt)) return void 0;
|
|
28
|
+
try {
|
|
29
|
+
return decodeOfrepBulk({ flags: u.flags });
|
|
30
|
+
} catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** A bulk entry as it is cached: the OFREP entry, without the decoder's `ok` tag. */
|
|
35
|
+
const toCached = (entry) => {
|
|
36
|
+
const { ok: _ok, ...rest } = entry;
|
|
37
|
+
return rest;
|
|
38
|
+
};
|
|
39
|
+
const deepFreeze = (u) => {
|
|
40
|
+
if (typeof u === "object" && u !== null && !Object.isFrozen(u)) {
|
|
41
|
+
Object.freeze(u);
|
|
42
|
+
for (const value of Object.values(u)) deepFreeze(value);
|
|
43
|
+
}
|
|
44
|
+
return u;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* An {@link EvaluationCache} in the memory of this isolate or process: a
|
|
48
|
+
* bounded least-recently-used map, with entries that expire `ttlMs` after they
|
|
49
|
+
* are stored. Expiry is checked on `get`; it starts no timer.
|
|
50
|
+
*
|
|
51
|
+
* It holds only plain data (a frozen copy of each value, never a promise or a
|
|
52
|
+
* `Response`), so on Cloudflare Workers requests can share it safely. Each
|
|
53
|
+
* isolate or process has its own: a new isolate starts empty.
|
|
54
|
+
*
|
|
55
|
+
* It trades freshness for fewer billable requests: for up to `ttlMs`, a
|
|
56
|
+
* context that is already cached does not see a flag change.
|
|
57
|
+
*/
|
|
58
|
+
const memoryCache = (options = {}) => {
|
|
59
|
+
const ttlMs = options.ttlMs ?? 3e4;
|
|
60
|
+
const maxEntries = options.maxEntries ?? 1e3;
|
|
61
|
+
if (!(Number.isFinite(ttlMs) && ttlMs > 0)) throw new RangeError("[toggld] `ttlMs` of memoryCache must be a positive, finite number of milliseconds.");
|
|
62
|
+
if (!(Number.isSafeInteger(maxEntries) && maxEntries > 0)) throw new RangeError("[toggld] `maxEntries` of memoryCache must be a positive integer.");
|
|
63
|
+
const entries = /* @__PURE__ */ new Map();
|
|
64
|
+
return {
|
|
65
|
+
get: (key) => {
|
|
66
|
+
const entry = entries.get(key);
|
|
67
|
+
if (entry === void 0) return void 0;
|
|
68
|
+
entries.delete(key);
|
|
69
|
+
if (Date.now() >= entry.expiresAt) return void 0;
|
|
70
|
+
entries.set(key, entry);
|
|
71
|
+
return entry.value;
|
|
72
|
+
},
|
|
73
|
+
set: (key, value) => {
|
|
74
|
+
entries.delete(key);
|
|
75
|
+
entries.set(key, {
|
|
76
|
+
expiresAt: Date.now() + ttlMs,
|
|
77
|
+
value: deepFreeze(structuredClone(value))
|
|
78
|
+
});
|
|
79
|
+
while (entries.size > maxEntries) entries.delete(entries.keys().next().value);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
// src/internal/serverless.ts
|
|
84
|
+
const DEFAULT_SERVERLESS_TIMEOUT_MS = 1500;
|
|
85
|
+
const BULK_PATH = "/ofrep/v1/evaluate/flags";
|
|
86
|
+
const positive = (value, fallback) => value !== void 0 && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
87
|
+
const assertCredentials = (options) => {
|
|
88
|
+
switch (syncCredentialProblem(options)) {
|
|
89
|
+
case "missing-key": throw new TypeError("[toggld] `sdkKey` is required. The serverless client needs a secret SDK key (ffk_…).");
|
|
90
|
+
case "public-token": throw new TypeError("[toggld] `sdkKey` is a public token (ffp_…). The serverless client is a server SDK and needs a secret SDK key (ffk_…): public tokens are for browsers, and their budget is shared per IP address, which a server's traffic would exhaust. In the browser, use @toggld/browser.");
|
|
91
|
+
case "invalid-endpoint": throw new TypeError(invalidEndpointMessage(options.endpoint));
|
|
92
|
+
case void 0: return;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const bulkOf = (flags) => ({
|
|
96
|
+
ok: true,
|
|
97
|
+
flags,
|
|
98
|
+
byKey: new Map(flags.map((entry) => [entry.key, entry]))
|
|
99
|
+
});
|
|
100
|
+
/** A copy for one caller: results are shared by the reads of a view, so nobody may change them. */
|
|
101
|
+
const own = (d) => ({
|
|
102
|
+
...d,
|
|
103
|
+
value: typeof d.value === "object" && d.value !== null ? structuredClone(d.value) : d.value,
|
|
104
|
+
flagMetadata: { ...d.flagMetadata }
|
|
105
|
+
});
|
|
106
|
+
const entryDetails = (entry, fallback) => own(entry.ok ? okDetails(entry.key, entry) : wireErrorDetails(fallback, entry));
|
|
107
|
+
const createServerlessCore = (options) => {
|
|
108
|
+
assertCredentials(options);
|
|
109
|
+
const timeoutMs = positive(options.timeoutMs, DEFAULT_SERVERLESS_TIMEOUT_MS);
|
|
110
|
+
const send = makeSend(options);
|
|
111
|
+
const cache = options.cache;
|
|
112
|
+
const cacheEndpoint = apiUrl(options.endpoint, "").toString();
|
|
113
|
+
const telemetry = options.telemetry === false ? void 0 : makeRequestTelemetry(send, {
|
|
114
|
+
...options.telemetry ? { telemetry: options.telemetry } : {},
|
|
115
|
+
timeoutMs
|
|
116
|
+
});
|
|
117
|
+
const record = (entry) => {
|
|
118
|
+
telemetry?.record(entry.key, entry.ok ? {
|
|
119
|
+
variant: entry.variant,
|
|
120
|
+
reason: entry.reason
|
|
121
|
+
} : {
|
|
122
|
+
variant: void 0,
|
|
123
|
+
reason: "ERROR"
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
let keyHash;
|
|
127
|
+
const warned = /* @__PURE__ */ new Set();
|
|
128
|
+
const warnOnce = (kind, message) => {
|
|
129
|
+
if (warned.has(kind)) return;
|
|
130
|
+
warned.add(kind);
|
|
131
|
+
console.warn(`[toggld] ${message} The serverless client sends the bulk request instead.`);
|
|
132
|
+
};
|
|
133
|
+
const mergedContext = (context) => ({
|
|
134
|
+
...options.defaultContext,
|
|
135
|
+
...context
|
|
136
|
+
});
|
|
137
|
+
const fetchBulk = async (context, signal) => {
|
|
138
|
+
try {
|
|
139
|
+
return await withTimeout(timeoutMs, async (s) => {
|
|
140
|
+
const response = await send("POST", BULK_PATH, { context }, s);
|
|
141
|
+
const status = response.status;
|
|
142
|
+
if (status >= 200 && status < 300) return bulkOf(decodeOfrepBulk(await readJson(response, `POST ${BULK_PATH}`)));
|
|
143
|
+
if (status >= 400 && status < 500) try {
|
|
144
|
+
const failure = decodeOfrepBulkFailure(await response.json());
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
errorCode: failure.errorCode,
|
|
148
|
+
message: failure.errorDetails ?? rejectedMessage(response)
|
|
149
|
+
};
|
|
150
|
+
} catch {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
errorCode: "GENERAL",
|
|
154
|
+
message: rejectedMessage(response)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
discard(response);
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
errorCode: "GENERAL",
|
|
161
|
+
message: `Unexpected response status ${status}`
|
|
162
|
+
};
|
|
163
|
+
}, signal);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
errorCode: codeFor(error),
|
|
168
|
+
message: describe(error)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const fromCache = async (cache, key) => {
|
|
173
|
+
let value;
|
|
174
|
+
try {
|
|
175
|
+
value = await withTimeout(timeoutMs, async () => cache.get(key));
|
|
176
|
+
} catch (error) {
|
|
177
|
+
warnOnce("get", `The evaluation cache's get failed: ${describe(error)}.`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (value === void 0 || value === null) return void 0;
|
|
181
|
+
const flags = decodeCached(value);
|
|
182
|
+
if (flags === void 0) {
|
|
183
|
+
warnOnce("shape", "The evaluation cache returned a value that is not a cached bulk result.");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
return bulkOf(flags);
|
|
187
|
+
};
|
|
188
|
+
const store = async (cache, key, result, waitUntil) => {
|
|
189
|
+
const value = {
|
|
190
|
+
storedAt: Date.now(),
|
|
191
|
+
flags: result.flags.map(toCached)
|
|
192
|
+
};
|
|
193
|
+
const write = (async () => cache.set(key, value))().then(() => {}, (error) => warnOnce("set", `The evaluation cache's set failed: ${describe(error)}.`));
|
|
194
|
+
if (waitUntil !== void 0) return waitUntil(write);
|
|
195
|
+
await withTimeout(timeoutMs, () => write).catch(() => {});
|
|
196
|
+
};
|
|
197
|
+
const resolve = async (context, canonical, view, signal) => {
|
|
198
|
+
let key;
|
|
199
|
+
if (cache !== void 0) {
|
|
200
|
+
try {
|
|
201
|
+
keyHash ??= await sha256Hex(options.sdkKey);
|
|
202
|
+
key = await cacheKeyFor(cacheEndpoint, keyHash, canonical);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
warnOnce("key", `Could not compute the evaluation cache key: ${describe(error)}.`);
|
|
205
|
+
}
|
|
206
|
+
if (key !== void 0) {
|
|
207
|
+
const hit = await fromCache(cache, key);
|
|
208
|
+
if (hit !== void 0) return hit;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const result = await fetchBulk(context, view.single ? signal : void 0);
|
|
212
|
+
if (cache !== void 0 && key !== void 0 && result.ok) await store(cache, key, result, view.waitUntil);
|
|
213
|
+
return result;
|
|
214
|
+
};
|
|
215
|
+
const load = (view, context, signal) => {
|
|
216
|
+
const merged = mergedContext(context);
|
|
217
|
+
const canonical = canonicalJson(merged);
|
|
218
|
+
let result = view.results.get(canonical);
|
|
219
|
+
if (result === void 0) {
|
|
220
|
+
result = resolve(merged, canonical, view, signal);
|
|
221
|
+
view.results.set(canonical, result);
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
};
|
|
225
|
+
const evaluate = async (view, key, defaultValue, context, signal) => {
|
|
226
|
+
const bulk = await load(view, context, signal);
|
|
227
|
+
try {
|
|
228
|
+
if (!bulk.ok) return errorDetails(key, defaultValue, bulk.errorCode, bulk.message);
|
|
229
|
+
const entry = bulk.byKey.get(key);
|
|
230
|
+
if (entry === void 0) return errorDetails(key, defaultValue, "FLAG_NOT_FOUND", `Flag '${key}' not found`);
|
|
231
|
+
record(entry);
|
|
232
|
+
return entryDetails(entry, defaultValue);
|
|
233
|
+
} finally {
|
|
234
|
+
telemetry?.check(view.waitUntil);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
view: (waitUntil, single = false) => ({
|
|
239
|
+
waitUntil,
|
|
240
|
+
single,
|
|
241
|
+
results: /* @__PURE__ */ new Map()
|
|
242
|
+
}),
|
|
243
|
+
load,
|
|
244
|
+
evaluate,
|
|
245
|
+
typed: async (view, key, defaultValue, context, type, signal) => enforceType(await evaluate(view, key, defaultValue, context, signal), key, defaultValue, type),
|
|
246
|
+
evaluateAll: async (view, context, signal) => {
|
|
247
|
+
const bulk = await load(view, context, signal);
|
|
248
|
+
try {
|
|
249
|
+
if (!bulk.ok) return [];
|
|
250
|
+
for (const entry of bulk.flags) record(entry);
|
|
251
|
+
return bulk.flags.map((entry) => entryDetails(entry, null));
|
|
252
|
+
} finally {
|
|
253
|
+
telemetry?.check(view.waitUntil);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
flushTelemetry: () => telemetry?.flush() ?? Promise.resolve()
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
export { memoryCache as n, createServerlessCore as t };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { a as ErrorCode, c as FlagValue, l as Reason, o as EvaluationContext, s as FlagMetadata, t as EvaluationDetails } from "./types-BHn_vrQq.mjs";
|
|
2
|
+
/** One flag of a cached bulk result that resolved: the OFREP success entry. */
|
|
3
|
+
interface CachedFlagSuccess {
|
|
4
|
+
readonly key: string;
|
|
5
|
+
readonly value: FlagValue;
|
|
6
|
+
readonly reason: Reason;
|
|
7
|
+
readonly variant?: string;
|
|
8
|
+
readonly metadata?: FlagMetadata;
|
|
9
|
+
}
|
|
10
|
+
/** One flag of a cached bulk result that failed to resolve: the OFREP failure entry. */
|
|
11
|
+
interface CachedFlagFailure {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly errorCode: ErrorCode;
|
|
14
|
+
readonly errorDetails?: string;
|
|
15
|
+
readonly metadata?: FlagMetadata;
|
|
16
|
+
}
|
|
17
|
+
type CachedFlagEvaluation = CachedFlagSuccess | CachedFlagFailure;
|
|
18
|
+
/**
|
|
19
|
+
* What the serverless client stores in an {@link EvaluationCache}: the flags of
|
|
20
|
+
* one bulk evaluation (`POST /ofrep/v1/evaluate/flags`) for one evaluation
|
|
21
|
+
* context, and when it was stored. Plain JSON: `JSON.stringify` and
|
|
22
|
+
* `JSON.parse` round-trip it, so any key-value store can hold it.
|
|
23
|
+
*/
|
|
24
|
+
interface CachedEvaluations {
|
|
25
|
+
/** When the client stored this result, in milliseconds since the epoch (`Date.now()`). */
|
|
26
|
+
readonly storedAt: number;
|
|
27
|
+
readonly flags: ReadonlyArray<CachedFlagEvaluation>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A cache for bulk evaluation results, shared by requests (and, for a shared
|
|
31
|
+
* store, by isolates). The serverless client calls `get` before it sends a bulk
|
|
32
|
+
* request, and `set` after a successful one. **Expiry is the cache's job**: the
|
|
33
|
+
* client uses any value `get` returns, whatever its age. A value that `get`
|
|
34
|
+
* throws for, or that does not have the {@link CachedEvaluations} shape, is a
|
|
35
|
+
* miss. A `set` that throws is ignored.
|
|
36
|
+
*
|
|
37
|
+
* Keys start with `toggld:v1:` and are 64 hex characters after that: a SHA-256
|
|
38
|
+
* hash of the endpoint, a hash of the SDK key, and the evaluation context. They
|
|
39
|
+
* never contain the SDK key or the context.
|
|
40
|
+
*/
|
|
41
|
+
interface EvaluationCache {
|
|
42
|
+
get(key: string): CachedEvaluations | undefined | Promise<CachedEvaluations | undefined>;
|
|
43
|
+
set(key: string, value: CachedEvaluations): void | Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/** Options of {@link memoryCache}. */
|
|
46
|
+
interface MemoryCacheOptions {
|
|
47
|
+
/**
|
|
48
|
+
* How long an entry is used, in milliseconds. Default `30000`. A flag change
|
|
49
|
+
* can take up to this long to show for a context that is already cached.
|
|
50
|
+
*/
|
|
51
|
+
readonly ttlMs?: number;
|
|
52
|
+
/**
|
|
53
|
+
* How many entries are kept; the least recently used goes first. Default
|
|
54
|
+
* `1000`. An entry is one evaluation context's result, so this bounds the
|
|
55
|
+
* number of distinct contexts (usually users) held at once.
|
|
56
|
+
*/
|
|
57
|
+
readonly maxEntries?: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* An {@link EvaluationCache} in the memory of this isolate or process: a
|
|
61
|
+
* bounded least-recently-used map, with entries that expire `ttlMs` after they
|
|
62
|
+
* are stored. Expiry is checked on `get`; it starts no timer.
|
|
63
|
+
*
|
|
64
|
+
* It holds only plain data (a frozen copy of each value, never a promise or a
|
|
65
|
+
* `Response`), so on Cloudflare Workers requests can share it safely. Each
|
|
66
|
+
* isolate or process has its own: a new isolate starts empty.
|
|
67
|
+
*
|
|
68
|
+
* It trades freshness for fewer billable requests: for up to `ttlMs`, a
|
|
69
|
+
* context that is already cached does not see a flag change.
|
|
70
|
+
*/
|
|
71
|
+
declare const memoryCache: (options?: MemoryCacheOptions) => EvaluationCache;
|
|
72
|
+
/** Configuration for {@link createServerlessClient}. */
|
|
73
|
+
interface ServerlessClientOptions {
|
|
74
|
+
/**
|
|
75
|
+
* A **secret** SDK key (`ffk_…`), sent as `Authorization: Bearer <sdkKey>`.
|
|
76
|
+
* An evaluation key is enough. Public tokens (`ffp_…`) are for browsers, so
|
|
77
|
+
* `createServerlessClient` throws a `TypeError` for them.
|
|
78
|
+
*/
|
|
79
|
+
readonly sdkKey: string;
|
|
80
|
+
/**
|
|
81
|
+
* Base URL of the flag API, such as `https://api.toggld.io`. It may have a
|
|
82
|
+
* path prefix, for a proxy on your own domain (`https://flags.example.com/toggld`):
|
|
83
|
+
* requests go to `<endpoint>/ofrep/v1/evaluate/flags`.
|
|
84
|
+
*/
|
|
85
|
+
readonly endpoint: string;
|
|
86
|
+
/**
|
|
87
|
+
* Timeout of each bulk request (and each cache call), in milliseconds.
|
|
88
|
+
* Default `1500`. A request that runs out of time resolves every evaluation
|
|
89
|
+
* of its context to the caller's default with `GENERAL`.
|
|
90
|
+
*/
|
|
91
|
+
readonly timeoutMs?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Attributes merged into the evaluation context of every evaluation, under
|
|
94
|
+
* the per-call context (per-call attributes win).
|
|
95
|
+
*/
|
|
96
|
+
readonly defaultContext?: EvaluationContext;
|
|
97
|
+
/**
|
|
98
|
+
* Custom `fetch`. Defaults to the global `fetch`. On Cloudflare Workers, a
|
|
99
|
+
* service binding to your own proxy Worker fits here:
|
|
100
|
+
* `fetch: (input, init) => env.FLAG_PROXY.fetch(input, init)`.
|
|
101
|
+
*/
|
|
102
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
103
|
+
/**
|
|
104
|
+
* An optional cache of bulk results, keyed by endpoint, SDK key, and
|
|
105
|
+
* evaluation context. Without one, every request view sends its own bulk
|
|
106
|
+
* request (per context), and changes show on the next request. With one, a
|
|
107
|
+
* cached context sends no request until the cache expires the entry: fewer
|
|
108
|
+
* billable requests, older values. {@link memoryCache} keeps entries in the
|
|
109
|
+
* isolate's memory; any key-value store (Workers KV, the Cache API, Redis)
|
|
110
|
+
* can implement {@link EvaluationCache}.
|
|
111
|
+
*/
|
|
112
|
+
readonly cache?: EvaluationCache;
|
|
113
|
+
/**
|
|
114
|
+
* Evaluation telemetry, on by default. The API does not count bulk requests
|
|
115
|
+
* in your flag analytics (a bulk answer holds every flag, not the ones you
|
|
116
|
+
* read), so the client counts each read that resolves a flag, cache hits
|
|
117
|
+
* included, and sends the counts to `POST /v1/telemetry`: grouped per flag,
|
|
118
|
+
* variant, reason, and minute, with no evaluation context or values.
|
|
119
|
+
*
|
|
120
|
+
* A send is due when the oldest count is `flushIntervalMs` old (default
|
|
121
|
+
* `30000`) or 1,000 cells are counted, and a request view that reads a
|
|
122
|
+
* flag starts it: in the request's `waitUntil` when `forRequest(ctx)` has
|
|
123
|
+
* one, else without waiting for it (on AWS Lambda, which freezes after the
|
|
124
|
+
* handler returns, a send may then be cut off). Evaluations and responses
|
|
125
|
+
* never wait for telemetry, and at most one send runs at a time. Counts
|
|
126
|
+
* live in the client (one per isolate or process), so they are sent by a
|
|
127
|
+
* later request and lost if the isolate or process ends first.
|
|
128
|
+
*
|
|
129
|
+
* Sends go through the same `endpoint` and `fetch` as evaluations. Set
|
|
130
|
+
* `false` to send nothing: these reads are then not in analytics.
|
|
131
|
+
*/
|
|
132
|
+
readonly telemetry?: false | {
|
|
133
|
+
readonly flushIntervalMs?: number;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The request context {@link ServerlessClient.forRequest} reads: the Workers
|
|
138
|
+
* `ExecutionContext`, or any object with `waitUntil` (Vercel's `waitUntil`
|
|
139
|
+
* from `@vercel/functions`, a Next.js middleware `event`, …). An object
|
|
140
|
+
* without `waitUntil`, such as the AWS Lambda `context`, counts as none.
|
|
141
|
+
*/
|
|
142
|
+
interface WaitUntilContext {
|
|
143
|
+
readonly waitUntil?: ((promise: Promise<unknown>) => void) | undefined;
|
|
144
|
+
}
|
|
145
|
+
/** The evaluation surface for one request. Every method resolves; none rejects. */
|
|
146
|
+
interface ServerlessRequestClient {
|
|
147
|
+
/** Resolve a flag, returning full details with an untyped (JSON) value. */
|
|
148
|
+
evaluate(key: string, defaultValue: FlagValue, context?: EvaluationContext): Promise<EvaluationDetails<FlagValue>>;
|
|
149
|
+
/**
|
|
150
|
+
* Every flag of the environment for this context, from the same bulk request
|
|
151
|
+
* as the other reads. Empty if that request failed.
|
|
152
|
+
*/
|
|
153
|
+
evaluateAll(context?: EvaluationContext): Promise<ReadonlyArray<EvaluationDetails<FlagValue>>>;
|
|
154
|
+
getBooleanValue(key: string, defaultValue: boolean, context?: EvaluationContext): Promise<boolean>;
|
|
155
|
+
getBooleanDetails(key: string, defaultValue: boolean, context?: EvaluationContext): Promise<EvaluationDetails<boolean>>;
|
|
156
|
+
getStringValue(key: string, defaultValue: string, context?: EvaluationContext): Promise<string>;
|
|
157
|
+
getStringDetails(key: string, defaultValue: string, context?: EvaluationContext): Promise<EvaluationDetails<string>>;
|
|
158
|
+
getNumberValue(key: string, defaultValue: number, context?: EvaluationContext): Promise<number>;
|
|
159
|
+
getNumberDetails(key: string, defaultValue: number, context?: EvaluationContext): Promise<EvaluationDetails<number>>;
|
|
160
|
+
getObjectValue<T extends FlagValue>(key: string, defaultValue: T, context?: EvaluationContext): Promise<T>;
|
|
161
|
+
getObjectDetails<T extends FlagValue>(key: string, defaultValue: T, context?: EvaluationContext): Promise<EvaluationDetails<T>>;
|
|
162
|
+
}
|
|
163
|
+
/** A serverless client. Create one per isolate or process (in module scope), and a request view per request. */
|
|
164
|
+
interface ServerlessClient {
|
|
165
|
+
/**
|
|
166
|
+
* The evaluation surface for one incoming request. Its first evaluation for
|
|
167
|
+
* a context sends one bulk request; later reads with the same context
|
|
168
|
+
* (`defaultContext` merged in) use that answer, and concurrent first reads
|
|
169
|
+
* share it. A different context sends its own bulk request.
|
|
170
|
+
*
|
|
171
|
+
* `ctx` carries the request's `waitUntil` (Workers `ctx`, Vercel). With
|
|
172
|
+
* one, cache writes and telemetry sends run after the response. Without one
|
|
173
|
+
* (`forRequest()`, AWS Lambda, Node serverless functions), cache writes run
|
|
174
|
+
* inline, bounded by `timeoutMs`, and never fail an evaluation; telemetry
|
|
175
|
+
* sends start without being awaited (see {@link ServerlessClientOptions.telemetry}).
|
|
176
|
+
*
|
|
177
|
+
* Use the returned object only within that request, and create a new one
|
|
178
|
+
* for the next: on Workers, I/O started by one request cannot be awaited by
|
|
179
|
+
* another.
|
|
180
|
+
*/
|
|
181
|
+
forRequest(ctx?: WaitUntilContext | object): ServerlessRequestClient;
|
|
182
|
+
/**
|
|
183
|
+
* Send the counted reads now, whether or not a send is due (see
|
|
184
|
+
* {@link ServerlessClientOptions.telemetry}). If a send is already in
|
|
185
|
+
* flight, it waits for that send first, then sends what is left. Bounded by
|
|
186
|
+
* `timeoutMs` as a whole; it never rejects, and a failed send stays queued
|
|
187
|
+
* for retry. With `telemetry: false`, it does nothing.
|
|
188
|
+
*
|
|
189
|
+
* Runtimes with a `waitUntil` (Workers, Vercel) do not need it. On runtimes
|
|
190
|
+
* without one, such as AWS Lambda, which freezes the process when the
|
|
191
|
+
* handler returns, call it before returning, or in the platform's shutdown
|
|
192
|
+
* hook if there is one. Awaiting it delays the response by up to
|
|
193
|
+
* `timeoutMs`.
|
|
194
|
+
*/
|
|
195
|
+
flushTelemetry(): Promise<void>;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Create a serverless client. Call it once per isolate or process, in module
|
|
199
|
+
* scope (or lazily on the first request, where Worker bindings are
|
|
200
|
+
* available). It makes no request until the first evaluation.
|
|
201
|
+
*
|
|
202
|
+
* Throws a `TypeError` for options that can never work: no `sdkKey`, a public
|
|
203
|
+
* token (`ffp_…`), or an `endpoint` that is not an http(s) URL.
|
|
204
|
+
*/
|
|
205
|
+
declare const createServerlessClient: (options: ServerlessClientOptions) => ServerlessClient;
|
|
206
|
+
export { createServerlessClient as a, CachedFlagFailure as c, MemoryCacheOptions as d, memoryCache as f, WaitUntilContext as i, CachedFlagSuccess as l, ServerlessClientOptions as n, CachedEvaluations as o, ServerlessRequestClient as r, CachedFlagEvaluation as s, ServerlessClient as t, EvaluationCache as u };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { a as ErrorCode, c as FlagValue, l as Reason, o as EvaluationContext, s as FlagMetadata, t as EvaluationDetails } from "./types-BHn_vrQq.mjs";
|
|
2
|
+
import { n as ToggldClientShape, t as ToggldClient } from "./effectService-CGZnDaqS.mjs";
|
|
3
|
+
import { c as CachedFlagFailure, d as MemoryCacheOptions, f as memoryCache, l as CachedFlagSuccess, n as ServerlessClientOptions, o as CachedEvaluations, s as CachedFlagEvaluation, u as EvaluationCache } from "./serverless-e__iCjpQ.mjs";
|
|
4
|
+
import { Context, Effect, Layer } from "effect";
|
|
5
|
+
declare const WaitUntil_base: Context.ServiceClass<WaitUntil, "@toggld/js/serverless/WaitUntil", {
|
|
6
|
+
readonly waitUntil: (promise: Promise<unknown>) => void;
|
|
7
|
+
}>;
|
|
8
|
+
/**
|
|
9
|
+
* The current request's `waitUntil`. Provide it per request (Workers, Vercel)
|
|
10
|
+
* with {@link withRequest}, {@link withWaitUntil}, or {@link waitUntilLayer};
|
|
11
|
+
* leave it out where the runtime freezes after the handler returns (AWS
|
|
12
|
+
* Lambda). It only affects cache writes and telemetry sends.
|
|
13
|
+
*/
|
|
14
|
+
export declare class WaitUntil extends WaitUntil_base {}
|
|
15
|
+
declare const RequestScope_base: Context.ServiceClass<RequestScope, "@toggld/js/serverless/RequestScope", {
|
|
16
|
+
readonly views: Map<unknown, unknown>;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* The evaluation state of one incoming request: its bulk results. Create it
|
|
20
|
+
* with {@link withRequest} or {@link requestLayer}, once per request; do not
|
|
21
|
+
* share it between requests.
|
|
22
|
+
*/
|
|
23
|
+
export declare class RequestScope extends RequestScope_base {}
|
|
24
|
+
/** What {@link withWaitUntil} accepts: a Workers `ExecutionContext`, or any object with `waitUntil`. */
|
|
25
|
+
export interface WaitUntilSource {
|
|
26
|
+
readonly waitUntil: (promise: Promise<unknown>) => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run `self` as one incoming request: a fresh {@link RequestScope} (one bulk
|
|
30
|
+
* request per context), plus {@link WaitUntil} when `ctx` has a `waitUntil`
|
|
31
|
+
* (a Workers `ExecutionContext`, Vercel). Without `ctx` (AWS Lambda), cache
|
|
32
|
+
* writes run inline.
|
|
33
|
+
*/
|
|
34
|
+
export declare const withRequest: (ctx?: WaitUntilSource | object) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, WaitUntil | RequestScope>>;
|
|
35
|
+
/** {@link withRequest} as a layer. Build it once per incoming request. */
|
|
36
|
+
export declare const requestLayer: (ctx?: WaitUntilSource | object) => Layer.Layer<RequestScope | WaitUntil>;
|
|
37
|
+
/** The same as {@link withRequest} with a `waitUntil` source: a request scope and its {@link WaitUntil}. */
|
|
38
|
+
export declare const withWaitUntil: (source: WaitUntilSource) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, WaitUntil | RequestScope>>;
|
|
39
|
+
/** {@link WaitUntil} and a fresh {@link RequestScope} as a layer, for one request. */
|
|
40
|
+
export declare const waitUntilLayer: (source: WaitUntilSource) => Layer.Layer<RequestScope | WaitUntil>;
|
|
41
|
+
/**
|
|
42
|
+
* A `Layer` providing {@link ToggldClient} with one serverless client for the
|
|
43
|
+
* layer's scope. Build it once per isolate or process (a module-scope
|
|
44
|
+
* `ManagedRuntime`), not per request, and provide {@link withRequest} per
|
|
45
|
+
* request. Building it makes no request, and it holds no resource.
|
|
46
|
+
*
|
|
47
|
+
* Options that can never work (no `sdkKey`, a public token, or an invalid
|
|
48
|
+
* `endpoint`) make the layer die with a `TypeError`.
|
|
49
|
+
*/
|
|
50
|
+
export declare const layer: (options: ServerlessClientOptions) => Layer.Layer<ToggldClient>;
|
|
51
|
+
export { type CachedEvaluations, type CachedFlagEvaluation, type CachedFlagFailure, type CachedFlagSuccess, type ErrorCode, type EvaluationCache, type EvaluationContext, type EvaluationDetails, type FlagMetadata, type FlagValue, type MemoryCacheOptions, type Reason, type ServerlessClientOptions, ToggldClient, type ToggldClientShape, memoryCache };
|