@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
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { d as discard, h as withTimeout, m as refusalMessage, t as WireError } from "./wire-CrkBeqnM.mjs";
|
|
2
|
+
// src/internal/telemetry.ts
|
|
3
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 3e4;
|
|
4
|
+
const MAX_CELLS = 1e4;
|
|
5
|
+
const MAX_COUNT = 1e9;
|
|
6
|
+
const BATCH_SIZE = 1e3;
|
|
7
|
+
/** A client that does not report: file mode, or `telemetry: false`. */
|
|
8
|
+
const noTelemetry = {
|
|
9
|
+
record: () => {},
|
|
10
|
+
flush: () => Promise.resolve(),
|
|
11
|
+
close: () => Promise.resolve()
|
|
12
|
+
};
|
|
13
|
+
/** The configured flush interval, or the default for a missing or unusable one. */
|
|
14
|
+
const flushInterval = (telemetry) => {
|
|
15
|
+
const configured = telemetry === false ? void 0 : telemetry?.flushIntervalMs;
|
|
16
|
+
return configured !== void 0 && Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_FLUSH_INTERVAL_MS;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The counting half: bounded, synchronous `record`, and `take`, which turns the
|
|
20
|
+
* counts so far into batches ready to send (each with a fresh `windowId`) and
|
|
21
|
+
* starts a new window.
|
|
22
|
+
*/
|
|
23
|
+
const makeRollups = () => {
|
|
24
|
+
let cells = /* @__PURE__ */ new Map();
|
|
25
|
+
let openedAt;
|
|
26
|
+
return {
|
|
27
|
+
isEmpty: () => cells.size === 0,
|
|
28
|
+
size: () => cells.size,
|
|
29
|
+
openedAt: () => openedAt,
|
|
30
|
+
take: (instanceId) => {
|
|
31
|
+
const rollups = [...cells.values()];
|
|
32
|
+
cells = /* @__PURE__ */ new Map();
|
|
33
|
+
openedAt = void 0;
|
|
34
|
+
const batches = [];
|
|
35
|
+
for (let i = 0; i < rollups.length; i += BATCH_SIZE) batches.push({
|
|
36
|
+
sdkInstanceId: instanceId,
|
|
37
|
+
windowId: crypto.randomUUID(),
|
|
38
|
+
rollups: rollups.slice(i, i + BATCH_SIZE)
|
|
39
|
+
});
|
|
40
|
+
return batches;
|
|
41
|
+
},
|
|
42
|
+
record: (flagKey, resolution) => {
|
|
43
|
+
if (flagKey.length === 0 || flagKey.length > 256 || (resolution.variant?.length ?? 0) > 256) return;
|
|
44
|
+
const variant = resolution.variant ?? "";
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const bucketStart = (/* @__PURE__ */ new Date(Math.floor(now / 6e4) * 6e4)).toISOString();
|
|
47
|
+
const key = JSON.stringify([
|
|
48
|
+
flagKey,
|
|
49
|
+
variant,
|
|
50
|
+
resolution.reason,
|
|
51
|
+
bucketStart
|
|
52
|
+
]);
|
|
53
|
+
const cell = cells.get(key);
|
|
54
|
+
if (cell) {
|
|
55
|
+
if (cell.count < MAX_COUNT) cells.set(key, {
|
|
56
|
+
...cell,
|
|
57
|
+
count: cell.count + 1
|
|
58
|
+
});
|
|
59
|
+
} else if (cells.size < MAX_CELLS) cells.set(key, {
|
|
60
|
+
flagKey,
|
|
61
|
+
variant,
|
|
62
|
+
reason: resolution.reason,
|
|
63
|
+
bucketStart,
|
|
64
|
+
count: 1
|
|
65
|
+
});
|
|
66
|
+
else return;
|
|
67
|
+
openedAt ??= now;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/** Send one batch; `true` once the API acknowledged it. Never rejects. */
|
|
72
|
+
const sendBatch = async (send, batch, timeoutMs, signal) => {
|
|
73
|
+
try {
|
|
74
|
+
const response = await withTimeout(timeoutMs, (s) => send("POST", "/v1/telemetry", batch, s), signal);
|
|
75
|
+
discard(response);
|
|
76
|
+
return response.status >= 200 && response.status < 300;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const startTelemetry = (send, options) => {
|
|
82
|
+
const instanceId = crypto.randomUUID();
|
|
83
|
+
const interval = flushInterval(options.telemetry);
|
|
84
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
85
|
+
const rollups = makeRollups();
|
|
86
|
+
const pending = [];
|
|
87
|
+
let tail = Promise.resolve();
|
|
88
|
+
const serialized = (signal) => {
|
|
89
|
+
const run = tail.then(() => flushNow(signal));
|
|
90
|
+
tail = run.catch(() => {});
|
|
91
|
+
return run;
|
|
92
|
+
};
|
|
93
|
+
const flushNow = async (signal) => {
|
|
94
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
95
|
+
if (pending.length === 0) pending.push(...rollups.take(instanceId));
|
|
96
|
+
while (pending.length > 0) {
|
|
97
|
+
if (!await sendBatch(send, pending[0], timeoutMs, signal)) return;
|
|
98
|
+
pending.shift();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const periodic = new AbortController();
|
|
103
|
+
let timer;
|
|
104
|
+
const schedule = () => {
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
serialized(periodic.signal).then(() => {
|
|
107
|
+
if (!periodic.signal.aborted) schedule();
|
|
108
|
+
});
|
|
109
|
+
}, interval);
|
|
110
|
+
if (options.unref === true) timer?.unref?.();
|
|
111
|
+
};
|
|
112
|
+
schedule();
|
|
113
|
+
let closing;
|
|
114
|
+
return {
|
|
115
|
+
flush: () => serialized(void 0),
|
|
116
|
+
close: () => closing ??= (async () => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
periodic.abort();
|
|
119
|
+
await withTimeout(timeoutMs, (signal) => serialized(signal)).catch(() => {});
|
|
120
|
+
})(),
|
|
121
|
+
record: rollups.record
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* {@link startTelemetry} on the first count: no id, timer, or request before
|
|
126
|
+
* a client has something to report. Remote mode uses it, since most remote
|
|
127
|
+
* clients only evaluate single flags, which the API counts itself.
|
|
128
|
+
*/
|
|
129
|
+
const lazyTelemetry = (start) => {
|
|
130
|
+
let started;
|
|
131
|
+
let closed = false;
|
|
132
|
+
return {
|
|
133
|
+
record: (flagKey, resolution) => {
|
|
134
|
+
if (closed) return;
|
|
135
|
+
(started ??= start()).record(flagKey, resolution);
|
|
136
|
+
},
|
|
137
|
+
flush: () => started?.flush() ?? Promise.resolve(),
|
|
138
|
+
close: () => {
|
|
139
|
+
closed = true;
|
|
140
|
+
return started?.close() ?? Promise.resolve();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
/** How often `flush` looks whether a send in flight has ended. */
|
|
145
|
+
const FLUSH_POLL_MS = 25;
|
|
146
|
+
/**
|
|
147
|
+
* Telemetry flushed by the requests that record it, for runtimes where
|
|
148
|
+
* nothing may run between requests (the serverless client, ADR-0020). All of
|
|
149
|
+
* its state is plain data (counts, numbers, batches), never a promise, so
|
|
150
|
+
* any request of the isolate or process may send it: on Workers, I/O started
|
|
151
|
+
* by one request cannot be awaited by another.
|
|
152
|
+
*
|
|
153
|
+
* - **Due** when the oldest count not sent is `flushIntervalMs` old (default
|
|
154
|
+
* 30 seconds), there are {@link REQUEST_FLUSH_CELLS} cells, or a failed
|
|
155
|
+
* window waits for its retry; checked by the request that records or
|
|
156
|
+
* finishes a read ({@link RequestTelemetry.check}).
|
|
157
|
+
* - **At most one send in flight.** It holds a lease (the time its sends may
|
|
158
|
+
* take, with their timeouts) instead of a promise: a runtime may stop a
|
|
159
|
+
* `waitUntil` without settling it, and the lease lets a later request send.
|
|
160
|
+
* - **Failed windows** stay queued with their `windowId` and are retried
|
|
161
|
+
* first, one interval later; meanwhile new counts add up in the bounded
|
|
162
|
+
* cells. The queue holds at most one take (10,000 cells, so at most 10
|
|
163
|
+
* batches of 1,000). The API does not deduplicate windows, so a window
|
|
164
|
+
* resent after a lost response is counted twice, which is accepted.
|
|
165
|
+
* - Nothing random runs before the first send: Workers refuse random values
|
|
166
|
+
* in global scope, where the client is created. The instance id is made on
|
|
167
|
+
* the first send.
|
|
168
|
+
*/
|
|
169
|
+
const makeRequestTelemetry = (send, options) => {
|
|
170
|
+
const interval = flushInterval(options.telemetry);
|
|
171
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
172
|
+
const rollups = makeRollups();
|
|
173
|
+
let instanceId;
|
|
174
|
+
let pending = [];
|
|
175
|
+
let lease = {
|
|
176
|
+
id: 0,
|
|
177
|
+
until: 0
|
|
178
|
+
};
|
|
179
|
+
let leases = 0;
|
|
180
|
+
let retryAt = 0;
|
|
181
|
+
const due = (now) => {
|
|
182
|
+
if (now < lease.until || now < retryAt) return false;
|
|
183
|
+
if (pending.length > 0 || rollups.size() >= 1e3) return true;
|
|
184
|
+
const openedAt = rollups.openedAt();
|
|
185
|
+
return openedAt !== void 0 && now - openedAt >= interval;
|
|
186
|
+
};
|
|
187
|
+
const run = async () => {
|
|
188
|
+
const id = ++leases;
|
|
189
|
+
try {
|
|
190
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
191
|
+
if (pending.length === 0 && !rollups.isEmpty()) pending = rollups.take(instanceId ??= crypto.randomUUID());
|
|
192
|
+
const batches = [...pending];
|
|
193
|
+
if (batches.length === 0) return;
|
|
194
|
+
lease = {
|
|
195
|
+
id,
|
|
196
|
+
until: Date.now() + timeoutMs * (batches.length + 1)
|
|
197
|
+
};
|
|
198
|
+
for (const batch of batches) {
|
|
199
|
+
if (!await sendBatch(send, batch, timeoutMs, void 0)) {
|
|
200
|
+
retryAt = Date.now() + interval;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const index = pending.indexOf(batch);
|
|
204
|
+
if (index >= 0) pending.splice(index, 1);
|
|
205
|
+
retryAt = 0;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
if (lease.id === id) lease = {
|
|
210
|
+
id,
|
|
211
|
+
until: 0
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
record: rollups.record,
|
|
217
|
+
check: (waitUntil) => {
|
|
218
|
+
if (!due(Date.now())) return;
|
|
219
|
+
const sending = run().catch(() => {});
|
|
220
|
+
if (waitUntil !== void 0) waitUntil(sending);
|
|
221
|
+
},
|
|
222
|
+
flush: async () => {
|
|
223
|
+
const deadline = Date.now() + timeoutMs;
|
|
224
|
+
while (Date.now() < lease.until) {
|
|
225
|
+
const left = deadline - Date.now();
|
|
226
|
+
if (left <= 0) return;
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(FLUSH_POLL_MS, left)));
|
|
228
|
+
}
|
|
229
|
+
const left = deadline - Date.now();
|
|
230
|
+
if (left <= 0) return;
|
|
231
|
+
await withTimeout(left, () => run()).catch(() => {});
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
// src/internal/remote.ts
|
|
236
|
+
/** The details of an OFREP success. */
|
|
237
|
+
const okDetails = (key, s) => ({
|
|
238
|
+
flagKey: key,
|
|
239
|
+
value: s.value,
|
|
240
|
+
variant: s.variant,
|
|
241
|
+
reason: s.reason,
|
|
242
|
+
errorCode: void 0,
|
|
243
|
+
errorMessage: void 0,
|
|
244
|
+
flagMetadata: s.metadata ?? {}
|
|
245
|
+
});
|
|
246
|
+
/** The details of an OFREP failure: the caller's `fallback` with the server's error code. */
|
|
247
|
+
const wireErrorDetails = (fallback, e) => ({
|
|
248
|
+
flagKey: e.key,
|
|
249
|
+
value: fallback,
|
|
250
|
+
variant: void 0,
|
|
251
|
+
reason: "ERROR",
|
|
252
|
+
errorCode: e.errorCode,
|
|
253
|
+
errorMessage: e.errorDetails,
|
|
254
|
+
flagMetadata: e.metadata ?? {}
|
|
255
|
+
});
|
|
256
|
+
/** Why the API refused an OFREP request whose response is not an OFREP failure. */
|
|
257
|
+
const rejectedMessage = (response) => refusalMessage(response, "The SDK key cannot evaluate flags (HTTP 403).");
|
|
258
|
+
/** A body of the wrong shape is a `PARSE_ERROR`; anything else (network, timeout, not JSON) is `GENERAL`. */
|
|
259
|
+
const codeFor = (e) => e instanceof WireError ? "PARSE_ERROR" : "GENERAL";
|
|
260
|
+
// ../spec/src/index.ts
|
|
261
|
+
/** True when `value` is assignable to a flag declared as `type`. */
|
|
262
|
+
const valueMatchesType = (value, type) => {
|
|
263
|
+
switch (type) {
|
|
264
|
+
case "boolean": return typeof value === "boolean";
|
|
265
|
+
case "string": return typeof value === "string";
|
|
266
|
+
case "number": return typeof value === "number" && Number.isFinite(value);
|
|
267
|
+
case "object": return typeof value === "object" && value !== null;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* The sync stream's WebSocket subprotocol name. Offered first by clients (the
|
|
272
|
+
* server echoes the first offer back, completing the RFC 6455 negotiation).
|
|
273
|
+
*/
|
|
274
|
+
const SYNC_PROTOCOL = "toggld.sync.v1";
|
|
275
|
+
/**
|
|
276
|
+
* Subprotocol entry carrying the bearer credential on a sync WebSocket
|
|
277
|
+
* upgrade: `toggld.bearer.<token>`. The standard `WebSocket` constructor
|
|
278
|
+
* cannot set an `Authorization` header, so the token rides the
|
|
279
|
+
* `Sec-WebSocket-Protocol` offer instead (a recognized pattern — it stays out
|
|
280
|
+
* of URLs and access logs). Servers accept either this or a real
|
|
281
|
+
* `Authorization: Bearer` header.
|
|
282
|
+
*/
|
|
283
|
+
const SYNC_BEARER_PROTOCOL_PREFIX = "toggld.bearer.";
|
|
284
|
+
/**
|
|
285
|
+
* Heartbeat on the evaluation stream and the sync stream: the client sends the
|
|
286
|
+
* text frame {@link STREAM_PING} (not JSON) and the server answers {@link STREAM_PONG}.
|
|
287
|
+
* Without it, a connection that a proxy or a sleeping laptop dropped silently
|
|
288
|
+
* would look open forever. Plain fixed strings, so a Durable Object can answer
|
|
289
|
+
* with an auto-response without waking from hibernation.
|
|
290
|
+
*/
|
|
291
|
+
const STREAM_PING = "ping";
|
|
292
|
+
const STREAM_PONG = "pong";
|
|
293
|
+
/** How often the client pings, and how long it waits for the pong before it reconnects. */
|
|
294
|
+
const STREAM_PING_INTERVAL_MS = 3e4;
|
|
295
|
+
const STREAM_PONG_TIMEOUT_MS = 1e4;
|
|
296
|
+
const TARGETING_KEY = "targetingKey";
|
|
297
|
+
/** Reads the targeting key from a context, if present and a non-empty string. */
|
|
298
|
+
const targetingKeyOf = (ctx) => {
|
|
299
|
+
const v = ctx[TARGETING_KEY];
|
|
300
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
301
|
+
};
|
|
302
|
+
/** Reads an arbitrary attribute, treating `targetingKey` like any other field. */
|
|
303
|
+
const attributeOf = (ctx, attribute) => Object.prototype.hasOwnProperty.call(ctx, attribute) ? ctx[attribute] : void 0;
|
|
304
|
+
const Resolution = {
|
|
305
|
+
/** A successful resolution. */
|
|
306
|
+
ok: (args) => ({
|
|
307
|
+
value: args.value,
|
|
308
|
+
variant: args.variant,
|
|
309
|
+
reason: args.reason,
|
|
310
|
+
errorCode: void 0,
|
|
311
|
+
errorMessage: void 0,
|
|
312
|
+
flagMetadata: args.flagMetadata ?? {}
|
|
313
|
+
}),
|
|
314
|
+
/** An abnormal resolution carrying an error code and a fallback value. */
|
|
315
|
+
error: (args) => ({
|
|
316
|
+
value: args.value,
|
|
317
|
+
variant: args.variant,
|
|
318
|
+
reason: "ERROR",
|
|
319
|
+
errorCode: args.errorCode,
|
|
320
|
+
errorMessage: args.errorMessage,
|
|
321
|
+
flagMetadata: args.flagMetadata ?? {}
|
|
322
|
+
})
|
|
323
|
+
};
|
|
324
|
+
const isBoolean = (v) => typeof v === "boolean";
|
|
325
|
+
const isString = (v) => typeof v === "string";
|
|
326
|
+
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
327
|
+
const isObject = (v) => valueMatchesType(v, "object");
|
|
328
|
+
const GUARDS = {
|
|
329
|
+
boolean: isBoolean,
|
|
330
|
+
string: isString,
|
|
331
|
+
number: isNumber,
|
|
332
|
+
object: isObject
|
|
333
|
+
};
|
|
334
|
+
const errorDetails = (key, value, errorCode, errorMessage, flagMetadata = {}) => ({
|
|
335
|
+
flagKey: key,
|
|
336
|
+
value,
|
|
337
|
+
variant: void 0,
|
|
338
|
+
reason: "ERROR",
|
|
339
|
+
errorCode,
|
|
340
|
+
errorMessage,
|
|
341
|
+
flagMetadata: { ...flagMetadata }
|
|
342
|
+
});
|
|
343
|
+
/**
|
|
344
|
+
* Enforce the requested value type on resolved details: a value of another
|
|
345
|
+
* type serves the caller's default with `TYPE_MISMATCH`. Error details pass
|
|
346
|
+
* through unchanged.
|
|
347
|
+
*/
|
|
348
|
+
const enforceType = (d, key, defaultValue, type) => {
|
|
349
|
+
if (d.errorCode !== void 0 || GUARDS[type](d.value)) return d;
|
|
350
|
+
return errorDetails(key, defaultValue, "TYPE_MISMATCH", `Flag '${key}' value is not of type ${type}`, d.flagMetadata);
|
|
351
|
+
};
|
|
352
|
+
const syncCredentialProblem = (options) => {
|
|
353
|
+
const key = options.sdkKey;
|
|
354
|
+
if (typeof key !== "string" || key.trim() === "") return "missing-key";
|
|
355
|
+
if (key.startsWith("ffp_")) return "public-token";
|
|
356
|
+
let url;
|
|
357
|
+
try {
|
|
358
|
+
url = new URL(options.endpoint ?? "");
|
|
359
|
+
} catch {
|
|
360
|
+
url = void 0;
|
|
361
|
+
}
|
|
362
|
+
if (url === void 0 || url.protocol !== "https:" && url.protocol !== "http:") return "invalid-endpoint";
|
|
363
|
+
};
|
|
364
|
+
const invalidEndpointMessage = (endpoint) => `[toggld] \`endpoint\` must be the http(s) URL of the Toggld API, such as https://api.toggld.io (got ${JSON.stringify(endpoint)}).`;
|
|
365
|
+
export { wireErrorDetails as _, Resolution as a, noTelemetry as b, STREAM_PONG as c, SYNC_PROTOCOL as d, attributeOf as f, rejectedMessage as g, okDetails as h, syncCredentialProblem as i, STREAM_PONG_TIMEOUT_MS as l, codeFor as m, errorDetails as n, STREAM_PING as o, targetingKeyOf as p, invalidEndpointMessage as r, STREAM_PING_INTERVAL_MS as s, enforceType as t, SYNC_BEARER_PROTOCOL_PREFIX as u, lazyTelemetry as v, startTelemetry as x, makeRequestTelemetry as y };
|
|
@@ -0,0 +1,17 @@
|
|
|
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-BHn_vrQq.mjs";
|
|
2
|
+
import { n as ToggldClientShape, t as ToggldClient } from "./effectService-CGZnDaqS.mjs";
|
|
3
|
+
import { Layer } from "effect";
|
|
4
|
+
/**
|
|
5
|
+
* A self-contained `Layer` providing {@link ToggldClient}. Building it waits
|
|
6
|
+
* for the first snapshot for up to `sync.readyTimeoutMs` (default 5 s), like
|
|
7
|
+
* `ToggldClient.create`; set it to `0` not to wait, and `yield* flags.ready`
|
|
8
|
+
* where you need the data. In sync mode (the default) background sync lives
|
|
9
|
+
* in the layer's scope: closing it (e.g. `ManagedRuntime.dispose`) stops sync,
|
|
10
|
+
* closes the stream, and flushes telemetry.
|
|
11
|
+
*
|
|
12
|
+
* Options that can never sync — no `sdkKey`, a public token (`ffp_…`), or no
|
|
13
|
+
* valid `endpoint` in sync mode — make the layer die with a `TypeError`, as do
|
|
14
|
+
* `mode: "local"`, `sync.file`, and `file` without `mode: "file"`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const layer: (options: ToggldClientOptions) => Layer.Layer<ToggldClient>;
|
|
17
|
+
export { type ErrorCode, type EvaluationContext, type EvaluationDetails, type EvaluationMode, type FlagMetadata, type FlagValue, type Reason, type SyncOptions, ToggldClient, type ToggldClientOptions, type ToggldClientShape };
|
package/dist/effect.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { t as makeCore } from "./core-BQfyNOoT.mjs";
|
|
2
|
+
import { t as ToggldClient } from "./effectService-DPD-fNCI.mjs";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
// src/effect.ts
|
|
5
|
+
/**
|
|
6
|
+
* `@toggld/js/effect` — the native Effect API. Needs the optional `effect`
|
|
7
|
+
* peer dependency (`^4.0.5`); the default `@toggld/js` entry does not.
|
|
8
|
+
*
|
|
9
|
+
* The client as a scoped service: build a {@link layer} from your options,
|
|
10
|
+
* compose it into your runtime, and `yield* ToggldClient` to evaluate. The
|
|
11
|
+
* layer's scope owns the client: closing it stops sync, closes the stream, and
|
|
12
|
+
* sends remaining telemetry.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { Effect } from "effect"
|
|
16
|
+
* import { ToggldClient, layer } from "@toggld/js/effect"
|
|
17
|
+
*
|
|
18
|
+
* const program = Effect.gen(function* () {
|
|
19
|
+
* const flags = yield* ToggldClient
|
|
20
|
+
* return yield* flags.getBooleanValue("new-checkout", false, { targetingKey: "user-123" })
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* program.pipe(Effect.provide(layer({ sdkKey, endpoint })), Effect.runPromise)
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Like `ToggldClient.create`, the layer runs sync mode by default and waits
|
|
27
|
+
* for the first snapshot for up to `sync.readyTimeoutMs`; pass `mode: "file"`
|
|
28
|
+
* to evaluate from a flag file, or `mode: "remote"` to send each evaluation to
|
|
29
|
+
* the API instead.
|
|
30
|
+
*
|
|
31
|
+
* This is a thin wrapper over the dependency-free core in `./internal/core.ts`,
|
|
32
|
+
* which is the implementation of both entries. In-process (sync and file
|
|
33
|
+
* mode) evaluations are synchronous effects; remote ones are interruptible (interrupting one aborts
|
|
34
|
+
* its request).
|
|
35
|
+
*/
|
|
36
|
+
/** Lift a core call: a plain (in-process) result stays synchronous; a remote one is interruptible. */
|
|
37
|
+
const lift = (core, run) => core.synchronous ? Effect.sync(() => run()) : Effect.promise((signal) => Promise.resolve(run(signal)));
|
|
38
|
+
const fromCore = (core) => {
|
|
39
|
+
const typed = (key, defaultValue, context, type) => lift(core, (signal) => core.typed(key, defaultValue, context, type, signal));
|
|
40
|
+
const value = (details) => Effect.map(details, (d) => d.value);
|
|
41
|
+
return {
|
|
42
|
+
ready: Effect.promise(() => core.ready()),
|
|
43
|
+
flushTelemetry: Effect.promise(() => core.flushTelemetry()),
|
|
44
|
+
evaluate: (key, defaultValue, context) => lift(core, (signal) => core.evaluate(key, defaultValue, context, signal)),
|
|
45
|
+
evaluateAll: (context) => lift(core, (signal) => core.evaluateAll(context, signal)),
|
|
46
|
+
getBooleanDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "boolean"),
|
|
47
|
+
getBooleanValue: (key, defaultValue, context) => value(typed(key, defaultValue, context, "boolean")),
|
|
48
|
+
getStringDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "string"),
|
|
49
|
+
getStringValue: (key, defaultValue, context) => value(typed(key, defaultValue, context, "string")),
|
|
50
|
+
getNumberDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "number"),
|
|
51
|
+
getNumberValue: (key, defaultValue, context) => value(typed(key, defaultValue, context, "number")),
|
|
52
|
+
getObjectDetails: (key, defaultValue, context) => typed(key, defaultValue, context, "object"),
|
|
53
|
+
getObjectValue: (key, defaultValue, context) => value(typed(key, defaultValue, context, "object"))
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Create a client owned by the current scope: it closes when the scope does.
|
|
58
|
+
* Waits for the first snapshot like `ToggldClient.create`, bounded by
|
|
59
|
+
* `sync.readyTimeoutMs`.
|
|
60
|
+
*/
|
|
61
|
+
const make = (options) => Effect.acquireRelease(Effect.sync(() => makeCore(options)), (core) => Effect.promise(() => core.close())).pipe(Effect.tap((core) => Effect.promise(() => core.waitForReady())), Effect.map(fromCore));
|
|
62
|
+
/**
|
|
63
|
+
* A self-contained `Layer` providing {@link ToggldClient}. Building it waits
|
|
64
|
+
* for the first snapshot for up to `sync.readyTimeoutMs` (default 5 s), like
|
|
65
|
+
* `ToggldClient.create`; set it to `0` not to wait, and `yield* flags.ready`
|
|
66
|
+
* where you need the data. In sync mode (the default) background sync lives
|
|
67
|
+
* in the layer's scope: closing it (e.g. `ManagedRuntime.dispose`) stops sync,
|
|
68
|
+
* closes the stream, and flushes telemetry.
|
|
69
|
+
*
|
|
70
|
+
* Options that can never sync — no `sdkKey`, a public token (`ffp_…`), or no
|
|
71
|
+
* valid `endpoint` in sync mode — make the layer die with a `TypeError`, as do
|
|
72
|
+
* `mode: "local"`, `sync.file`, and `file` without `mode: "file"`.
|
|
73
|
+
*/
|
|
74
|
+
const layer = (options) => Layer.effect(ToggldClient, make(options));
|
|
75
|
+
export { ToggldClient, layer };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { c as FlagValue, o as EvaluationContext, t as EvaluationDetails } from "./types-BHn_vrQq.mjs";
|
|
2
|
+
import { Context, Effect } from "effect";
|
|
3
|
+
/** The evaluation surface of a Toggld client. Every method is non-failing. */
|
|
4
|
+
interface ToggldClientShape {
|
|
5
|
+
/**
|
|
6
|
+
* Completes once the client is ready to evaluate.
|
|
7
|
+
*
|
|
8
|
+
* - `@toggld/js/effect`: on the first synced snapshot in sync mode, the
|
|
9
|
+
* first read of the file in file mode;
|
|
10
|
+
* immediately in remote mode. The layer already waits for this for up to
|
|
11
|
+
* `sync.readyTimeoutMs`; this effect has no timeout of its own.
|
|
12
|
+
* - `@toggld/js/serverless/effect`: immediately (it is a remote client).
|
|
13
|
+
*
|
|
14
|
+
* In sync and file modes, until a snapshot arrives, evaluations resolve to
|
|
15
|
+
* the caller's default with `PROVIDER_NOT_READY`.
|
|
16
|
+
*/
|
|
17
|
+
readonly ready: Effect.Effect<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Send buffered telemetry. Failed windows remain queued for retry. Sync
|
|
20
|
+
* mode counts every evaluation; remote mode only `evaluateAll` entries (the
|
|
21
|
+
* API counts single evaluations); the serverless client every read that
|
|
22
|
+
* resolves a flag, and completes at once if a send is already in flight.
|
|
23
|
+
* Completes at once in file mode or with `telemetry: false`.
|
|
24
|
+
*/
|
|
25
|
+
readonly flushTelemetry: Effect.Effect<void>;
|
|
26
|
+
/** Resolve a flag, returning full details with an untyped (JSON) value. */
|
|
27
|
+
readonly evaluate: (key: string, defaultValue: FlagValue, context?: EvaluationContext) => Effect.Effect<EvaluationDetails<FlagValue>>;
|
|
28
|
+
/** Resolve every flag for the environment (one request in remote mode; in-process otherwise). */
|
|
29
|
+
readonly evaluateAll: (context?: EvaluationContext) => Effect.Effect<ReadonlyArray<EvaluationDetails<FlagValue>>>;
|
|
30
|
+
readonly getBooleanValue: (key: string, defaultValue: boolean, context?: EvaluationContext) => Effect.Effect<boolean>;
|
|
31
|
+
readonly getBooleanDetails: (key: string, defaultValue: boolean, context?: EvaluationContext) => Effect.Effect<EvaluationDetails<boolean>>;
|
|
32
|
+
readonly getStringValue: (key: string, defaultValue: string, context?: EvaluationContext) => Effect.Effect<string>;
|
|
33
|
+
readonly getStringDetails: (key: string, defaultValue: string, context?: EvaluationContext) => Effect.Effect<EvaluationDetails<string>>;
|
|
34
|
+
readonly getNumberValue: (key: string, defaultValue: number, context?: EvaluationContext) => Effect.Effect<number>;
|
|
35
|
+
readonly getNumberDetails: (key: string, defaultValue: number, context?: EvaluationContext) => Effect.Effect<EvaluationDetails<number>>;
|
|
36
|
+
readonly getObjectValue: <T extends FlagValue>(key: string, defaultValue: T, context?: EvaluationContext) => Effect.Effect<T>;
|
|
37
|
+
readonly getObjectDetails: <T extends FlagValue>(key: string, defaultValue: T, context?: EvaluationContext) => Effect.Effect<EvaluationDetails<T>>;
|
|
38
|
+
}
|
|
39
|
+
declare const ToggldClient_base: Context.ServiceClass<ToggldClient, "@toggld/js/ToggldClient", ToggldClientShape>;
|
|
40
|
+
/**
|
|
41
|
+
* The Toggld client as an Effect service. Build a layer from options and
|
|
42
|
+
* compose it into your runtime, then `yield* ToggldClient` to evaluate flags.
|
|
43
|
+
* One tag for both Effect entries: `layer` from `@toggld/js/effect` (sync,
|
|
44
|
+
* file, or remote mode) and `layer` from `@toggld/js/serverless/effect` provide it, so
|
|
45
|
+
* program code is the same with either.
|
|
46
|
+
*/
|
|
47
|
+
declare class ToggldClient extends ToggldClient_base {}
|
|
48
|
+
export { ToggldClientShape as n, ToggldClient as t };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
// src/internal/effectService.ts
|
|
3
|
+
/**
|
|
4
|
+
* The Effect service shared by `@toggld/js/effect` and
|
|
5
|
+
* `@toggld/js/serverless/effect`: the {@link ToggldClient} tag and its
|
|
6
|
+
* {@link ToggldClientShape}. It imports only `effect` and types, so the
|
|
7
|
+
* serverless Effect entry can use it without the main client's `node:` imports.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The Toggld client as an Effect service. Build a layer from options and
|
|
11
|
+
* compose it into your runtime, then `yield* ToggldClient` to evaluate flags.
|
|
12
|
+
* One tag for both Effect entries: `layer` from `@toggld/js/effect` (sync,
|
|
13
|
+
* file, or remote mode) and `layer` from `@toggld/js/serverless/effect` provide it, so
|
|
14
|
+
* program code is the same with either.
|
|
15
|
+
*/
|
|
16
|
+
var ToggldClient = class extends Context.Service()("@toggld/js/ToggldClient") {};
|
|
17
|
+
export { ToggldClient as t };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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-BHn_vrQq.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* A Toggld client. Create one with {@link ToggldClient.create} and {@link
|
|
4
|
+
* ToggldClient.close} it on shutdown to stop background sync.
|
|
5
|
+
*/
|
|
6
|
+
export declare class ToggldClient {
|
|
7
|
+
#private;
|
|
8
|
+
private constructor();
|
|
9
|
+
/**
|
|
10
|
+
* Create a client. In sync mode (the default) it resolves once the first
|
|
11
|
+
* flag snapshot has synced, or once `sync.readyTimeoutMs` (default 5 s) has
|
|
12
|
+
* elapsed. In that case it warns once, and the client serves caller defaults
|
|
13
|
+
* with `PROVIDER_NOT_READY` while sync keeps retrying in the background. In
|
|
14
|
+
* file mode it resolves after the first read of the file; in remote mode, at
|
|
15
|
+
* once.
|
|
16
|
+
*
|
|
17
|
+
* Rejects with a `TypeError` when a sync-mode client could never sync: no
|
|
18
|
+
* `sdkKey`, a public token (`ffp_…`), or no valid `endpoint`. Use a secret
|
|
19
|
+
* SDK key, or pass `mode: "remote"`. Also rejects options that no longer
|
|
20
|
+
* apply: `mode: "local"` (use `"sync"` or `"file"`), `sync.file` (use
|
|
21
|
+
* `mode: "file", file`), and `file` without `mode: "file"`.
|
|
22
|
+
*/
|
|
23
|
+
static create(options: ToggldClientOptions): Promise<ToggldClient>;
|
|
24
|
+
/**
|
|
25
|
+
* Resolves once the client has flag data to evaluate with (immediately in
|
|
26
|
+
* remote mode). Useful if `create` resolved on the ready timeout and you
|
|
27
|
+
* want to await sync explicitly. It has no timeout of its own, and it never
|
|
28
|
+
* resolves while the key or endpoint is wrong. Rejects if the client is
|
|
29
|
+
* closed first.
|
|
30
|
+
*/
|
|
31
|
+
ready(): Promise<void>;
|
|
32
|
+
/** Send buffered counts without failing evaluations. Failed windows remain queued. */
|
|
33
|
+
flushTelemetry(): Promise<void>;
|
|
34
|
+
/** Resolve a flag, returning full details with an untyped (JSON) value. */
|
|
35
|
+
evaluate(key: string, defaultValue: FlagValue, context?: EvaluationContext): Promise<EvaluationDetails<FlagValue>>;
|
|
36
|
+
/** Resolve every flag for the environment (one request in remote mode; in-process in sync and file modes). */
|
|
37
|
+
evaluateAll(context?: EvaluationContext): Promise<ReadonlyArray<EvaluationDetails<FlagValue>>>;
|
|
38
|
+
getBooleanValue(key: string, defaultValue: boolean, context?: EvaluationContext): Promise<boolean>;
|
|
39
|
+
getBooleanDetails(key: string, defaultValue: boolean, context?: EvaluationContext): Promise<EvaluationDetails<boolean>>;
|
|
40
|
+
getStringValue(key: string, defaultValue: string, context?: EvaluationContext): Promise<string>;
|
|
41
|
+
getStringDetails(key: string, defaultValue: string, context?: EvaluationContext): Promise<EvaluationDetails<string>>;
|
|
42
|
+
getNumberValue(key: string, defaultValue: number, context?: EvaluationContext): Promise<number>;
|
|
43
|
+
getNumberDetails(key: string, defaultValue: number, context?: EvaluationContext): Promise<EvaluationDetails<number>>;
|
|
44
|
+
getObjectValue<T extends FlagValue>(key: string, defaultValue: T, context?: EvaluationContext): Promise<T>;
|
|
45
|
+
getObjectDetails<T extends FlagValue>(key: string, defaultValue: T, context?: EvaluationContext): Promise<EvaluationDetails<T>>;
|
|
46
|
+
/**
|
|
47
|
+
* Stop background sync, close the stream, and send remaining telemetry
|
|
48
|
+
* (bounded by `timeoutMs`). The client is unusable afterwards. A sync-mode
|
|
49
|
+
* client's stream and poll (and a file-mode client's watcher) keep the process alive until you call this, so a
|
|
50
|
+
* script must close its client to exit.
|
|
51
|
+
*/
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export type { ErrorCode, EvaluationContext, EvaluationDetails, EvaluationMode, FlagMetadata, FlagValue, Reason, SyncOptions, ToggldClientOptions };
|