@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.
@@ -0,0 +1,447 @@
1
+ // src/internal/http.ts
2
+ /**
3
+ * The small HTTP layer the SDK needs: authenticated JSON requests through the
4
+ * configured `fetch`, and a timeout that bounds a whole request, body included.
5
+ */
6
+ /** A request that ran out of time. */
7
+ var TimeoutError = class extends Error {
8
+ constructor(ms) {
9
+ super(`The request timed out after ${ms} ms`);
10
+ this.name = "TimeoutError";
11
+ }
12
+ };
13
+ /** A request abandoned because its caller went away (the client closed). */
14
+ var AbortedError = class extends Error {
15
+ constructor() {
16
+ super("The request was cancelled");
17
+ this.name = "AbortedError";
18
+ }
19
+ };
20
+ /**
21
+ * Run `run` with a signal that aborts after `ms`, or when `outer` aborts. The
22
+ * returned promise settles at that moment even if `run` ignores its signal: a
23
+ * custom `fetch` that never settles must not stall a poll or a shutdown.
24
+ */
25
+ const withTimeout = (ms, run, outer) => new Promise((resolve, reject) => {
26
+ if (outer?.aborted) return reject(new AbortedError());
27
+ const controller = new AbortController();
28
+ let settled = false;
29
+ const done = () => {
30
+ settled = true;
31
+ clearTimeout(timer);
32
+ outer?.removeEventListener("abort", onOuter);
33
+ };
34
+ const onOuter = () => {
35
+ if (settled) return;
36
+ done();
37
+ const error = new AbortedError();
38
+ controller.abort(error);
39
+ reject(error);
40
+ };
41
+ const timer = setTimeout(() => {
42
+ if (settled) return;
43
+ done();
44
+ const error = new TimeoutError(ms);
45
+ controller.abort(error);
46
+ reject(error);
47
+ }, ms);
48
+ outer?.addEventListener("abort", onOuter, { once: true });
49
+ let pending;
50
+ try {
51
+ pending = run(controller.signal);
52
+ } catch (error) {
53
+ pending = Promise.reject(error);
54
+ }
55
+ pending.then((a) => {
56
+ if (settled) return;
57
+ done();
58
+ resolve(a);
59
+ }, (error) => {
60
+ if (settled) return;
61
+ done();
62
+ reject(error);
63
+ });
64
+ });
65
+ /**
66
+ * A transport failure: `fetch` itself threw. Node's `fetch` rejects with a bare
67
+ * "fetch failed" and keeps the reason (DNS, refused connection, TLS) in
68
+ * `cause`, so the message carries that too.
69
+ */
70
+ var TransportError = class extends Error {
71
+ constructor(method, url, cause) {
72
+ const inner = typeof cause === "object" && cause !== null && "cause" in cause ? cause.cause : void 0;
73
+ const reason = inner === void 0 ? describe(cause) : `${describe(cause)}: ${describe(inner)}`;
74
+ super(`Transport error (${method} ${url}): ${reason}`, { cause });
75
+ this.name = "TransportError";
76
+ }
77
+ };
78
+ /** A readable message for an arbitrary thrown value. */
79
+ const describe = (e) => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" ? e.message : String(e);
80
+ /**
81
+ * The URL of an API path under `endpoint`. The endpoint is a base URL, and it
82
+ * may carry a path prefix (a proxy on the customer's own domain, such as
83
+ * `https://flags.example.com/toggld`): trailing slashes are dropped and
84
+ * `path` (which starts with `/`) is appended, so the prefix is kept. A
85
+ * `new URL(path, endpoint)` join would drop it. Every request the SDK sends,
86
+ * and the sync stream's WebSocket URL, is built here.
87
+ */
88
+ const apiUrl = (endpoint, path) => {
89
+ const url = new URL(endpoint);
90
+ url.pathname = url.pathname.replace(/\/+$/, "") + path;
91
+ return url;
92
+ };
93
+ /**
94
+ * A request function bound to the endpoint and key, with a `URL` input.
95
+ * `encode` turns a JSON body into what `fetch` receives: the string itself by
96
+ * default (any runtime), or a UTF-8 `Buffer` in the main entry, which earlier
97
+ * versions passed to a custom `fetch`.
98
+ */
99
+ const makeSend = (options, encode = (json) => json) => {
100
+ const fetchImpl = options.fetch ?? globalThis.fetch;
101
+ const endpoint = options.endpoint ?? "";
102
+ const authorization = `Bearer ${options.sdkKey ?? ""}`;
103
+ return async (method, path, body, signal, extra) => {
104
+ const url = apiUrl(endpoint, path);
105
+ const headers = {
106
+ ...extra,
107
+ authorization
108
+ };
109
+ const init = {
110
+ method,
111
+ headers,
112
+ signal
113
+ };
114
+ if (body !== void 0) {
115
+ headers["content-type"] = "application/json";
116
+ init.body = encode(JSON.stringify(body));
117
+ }
118
+ try {
119
+ return await fetchImpl(url, init);
120
+ } catch (error) {
121
+ throw new TransportError(method, url.toString(), error);
122
+ }
123
+ };
124
+ };
125
+ /**
126
+ * Why the API refused a request whose body is not an answer: a rejected key
127
+ * (401), a key without access (403, worded by the caller), or a rate limit (429).
128
+ */
129
+ const refusalMessage = (response, forbidden) => {
130
+ switch (response.status) {
131
+ case 401: return "The API rejected the SDK key (HTTP 401). Check that `sdkKey` is a valid, unrevoked key.";
132
+ case 403: return forbidden;
133
+ case 429: {
134
+ const retryAfter = response.headers.get("retry-after");
135
+ return `The API rate limit was reached (HTTP 429)${retryAfter ? `. Retry after ${retryAfter} seconds` : ""}.`;
136
+ }
137
+ default: return `The API refused the request (HTTP ${response.status}).`;
138
+ }
139
+ };
140
+ /** Read a JSON body; a body that is not JSON is a transport-level failure, not a shape one. */
141
+ const readJson = async (response, request) => {
142
+ try {
143
+ return await response.json();
144
+ } catch (error) {
145
+ throw new Error(`The response body is not valid JSON (${response.status} ${request})`, { cause: error });
146
+ }
147
+ };
148
+ /** Drop a body nobody reads, so its connection can be reused. */
149
+ const discard = (response) => {
150
+ response.body?.cancel().catch(() => {});
151
+ };
152
+ // src/internal/wire.ts
153
+ /** A response or document that does not have the expected shape. */
154
+ var WireError = class extends Error {
155
+ constructor(message) {
156
+ super(message);
157
+ this.name = "WireError";
158
+ }
159
+ };
160
+ const fail = (path, expected) => {
161
+ throw new WireError(`Expected ${expected} at ${path}`);
162
+ };
163
+ const isRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
164
+ const has = (o, key) => Object.hasOwn(o, key);
165
+ const record = (u, path) => isRecord(u) ? u : fail(path, "an object");
166
+ const array = (u, path) => Array.isArray(u) ? u : fail(path, "an array");
167
+ const string = (u, path) => typeof u === "string" ? u : fail(path, "a string");
168
+ const boolean = (u, path) => typeof u === "boolean" ? u : fail(path, "a boolean");
169
+ const number = (u, path) => typeof u === "number" ? u : fail(path, "a number");
170
+ const finite = (u, path) => typeof u === "number" && Number.isFinite(u) ? u : fail(path, "a finite number");
171
+ const literal = (values, u, path) => typeof u === "string" && values.has(u) ? u : fail(path, `one of ${[...values].join(", ")}`);
172
+ const arrayOf = (u, path, item) => array(u, path).map((v, i) => item(v, `${path}[${i}]`));
173
+ /** Copy `key` into `out` when it is present, decoded; absent stays absent. */
174
+ const optional = (out, from, key, path, decode) => {
175
+ if (has(from, key)) out[key] = decode(from[key], `${path}.${key}`);
176
+ };
177
+ /**
178
+ * A JSON value: `null`, a boolean, a finite number, a string, or an array or
179
+ * plain object of those. Returned as is, not copied, as the schema does.
180
+ */
181
+ const isJson = (u) => {
182
+ if (u === null || typeof u === "string" || typeof u === "boolean") return true;
183
+ if (typeof u === "number") return Number.isFinite(u);
184
+ if (Array.isArray(u)) {
185
+ for (let i = 0; i < u.length; i++) if (!isJson(u[i])) return false;
186
+ return true;
187
+ }
188
+ if (typeof u !== "object") return false;
189
+ const proto = Object.getPrototypeOf(u);
190
+ if (proto !== null && proto !== Object.prototype) return false;
191
+ for (const key in u) if (has(u, key) && !isJson(u[key])) return false;
192
+ return true;
193
+ };
194
+ const json = (u, path) => isJson(u) ? u : fail(path, "a JSON value");
195
+ const REASON_LIST = [
196
+ "STATIC",
197
+ "DEFAULT",
198
+ "TARGETING_MATCH",
199
+ "SPLIT",
200
+ "CACHED",
201
+ "DISABLED",
202
+ "UNKNOWN",
203
+ "STALE",
204
+ "ERROR"
205
+ ];
206
+ const ERROR_CODE_LIST = [
207
+ "PROVIDER_NOT_READY",
208
+ "PROVIDER_FATAL",
209
+ "FLAG_NOT_FOUND",
210
+ "PARSE_ERROR",
211
+ "TYPE_MISMATCH",
212
+ "TARGETING_KEY_MISSING",
213
+ "INVALID_CONTEXT",
214
+ "GENERAL"
215
+ ];
216
+ const FLAG_TYPE_LIST = [
217
+ "boolean",
218
+ "string",
219
+ "number",
220
+ "object"
221
+ ];
222
+ const OPERATOR_LIST = [
223
+ "eq",
224
+ "ne",
225
+ "in",
226
+ "notIn",
227
+ "lt",
228
+ "lte",
229
+ "gt",
230
+ "gte",
231
+ "contains",
232
+ "startsWith",
233
+ "endsWith",
234
+ "exists",
235
+ "notExists"
236
+ ];
237
+ const LIST_MODE_LIST = [
238
+ "none",
239
+ "inline",
240
+ "remote"
241
+ ];
242
+ const REASONS = new Set(REASON_LIST);
243
+ const ERROR_CODES = new Set(ERROR_CODE_LIST);
244
+ const FLAG_TYPES = new Set(FLAG_TYPE_LIST);
245
+ const OPERATORS = new Set(OPERATOR_LIST);
246
+ const LIST_MODES = new Set(LIST_MODE_LIST);
247
+ const metadataValue = (u, path) => typeof u === "string" || typeof u === "boolean" || typeof u === "number" && Number.isFinite(u) ? u : fail(path, "a string, finite number, or boolean");
248
+ /** Flag metadata, copied (the result is a new object). */
249
+ const metadata = (u, path) => {
250
+ const o = record(u, path);
251
+ const out = {};
252
+ for (const key of Object.keys(o)) out[key] = metadataValue(o[key], `${path}.${key}`);
253
+ return out;
254
+ };
255
+ const decodeOfrepSuccess = (u, path = "response") => {
256
+ const o = record(u, path);
257
+ if (!has(o, "value")) fail(`${path}.value`, "a JSON value");
258
+ const out = {
259
+ key: string(o.key, `${path}.key`),
260
+ value: json(o.value, `${path}.value`),
261
+ reason: literal(REASONS, o.reason, `${path}.reason`)
262
+ };
263
+ optional(out, o, "variant", path, string);
264
+ optional(out, o, "metadata", path, metadata);
265
+ return out;
266
+ };
267
+ const decodeOfrepFailure = (u, path = "response") => {
268
+ const o = record(u, path);
269
+ const out = {
270
+ key: string(o.key, `${path}.key`),
271
+ errorCode: literal(ERROR_CODES, o.errorCode, `${path}.errorCode`)
272
+ };
273
+ optional(out, o, "errorDetails", path, string);
274
+ optional(out, o, "metadata", path, metadata);
275
+ return out;
276
+ };
277
+ /**
278
+ * A bulk response. Each entry is a success or a failure, tried in that order,
279
+ * as the schema's union does; one malformed entry rejects the response.
280
+ */
281
+ const decodeOfrepBulk = (u) => {
282
+ const o = record(u, "response");
283
+ if (has(o, "metadata")) metadata(o.metadata, "response.metadata");
284
+ return arrayOf(o.flags, "response.flags", (entry, path) => {
285
+ try {
286
+ return {
287
+ ok: true,
288
+ ...decodeOfrepSuccess(entry, path)
289
+ };
290
+ } catch (error) {
291
+ if (!(error instanceof WireError)) throw error;
292
+ return {
293
+ ok: false,
294
+ ...decodeOfrepFailure(entry, path)
295
+ };
296
+ }
297
+ });
298
+ };
299
+ const decodeOfrepBulkFailure = (u) => {
300
+ const o = record(u, "response");
301
+ const out = { errorCode: literal(ERROR_CODES, o.errorCode, "response.errorCode") };
302
+ optional(out, o, "errorDetails", "response", string);
303
+ return out;
304
+ };
305
+ const condition = (u, path) => {
306
+ const o = record(u, path);
307
+ const out = {
308
+ attribute: string(o.attribute, `${path}.attribute`),
309
+ operator: literal(OPERATORS, o.operator, `${path}.operator`)
310
+ };
311
+ optional(out, o, "value", path, json);
312
+ return out;
313
+ };
314
+ const split = (u, path) => {
315
+ const o = record(u, path);
316
+ const weight = finite(o.weight, `${path}.weight`);
317
+ if (weight < 0) fail(`${path}.weight`, "a number of at least 0");
318
+ return {
319
+ variant: string(o.variant, `${path}.variant`),
320
+ weight
321
+ };
322
+ };
323
+ const serve = (u, path) => {
324
+ const o = record(u, path);
325
+ if (o.type === "variant") return {
326
+ type: "variant",
327
+ variant: string(o.variant, `${path}.variant`)
328
+ };
329
+ if (o.type === "rollout") {
330
+ const out = { type: "rollout" };
331
+ optional(out, o, "bucketBy", path, string);
332
+ out.splits = arrayOf(o.splits, `${path}.splits`, split);
333
+ return out;
334
+ }
335
+ return fail(`${path}.type`, `"variant" or "rollout"`);
336
+ };
337
+ const segmentRef = (u, path) => {
338
+ const o = record(u, path);
339
+ const out = { segment: string(o.segment, `${path}.segment`) };
340
+ optional(out, o, "negate", path, boolean);
341
+ return out;
342
+ };
343
+ const rule = (u, path) => {
344
+ const o = record(u, path);
345
+ const out = { id: string(o.id, `${path}.id`) };
346
+ optional(out, o, "description", path, string);
347
+ out.conditions = arrayOf(o.conditions, `${path}.conditions`, condition);
348
+ optional(out, o, "segments", path, (v, p) => arrayOf(v, p, segmentRef));
349
+ out.serve = serve(o.serve, `${path}.serve`);
350
+ return out;
351
+ };
352
+ const variants = (u, path) => {
353
+ const o = record(u, path);
354
+ const out = {};
355
+ for (const key of Object.keys(o)) out[key] = json(o[key], `${path}.${key}`);
356
+ return out;
357
+ };
358
+ const decodeFlag = (u, path = "flag") => {
359
+ const o = record(u, path);
360
+ const out = { key: string(o.key, `${path}.key`) };
361
+ optional(out, o, "name", path, string);
362
+ optional(out, o, "description", path, string);
363
+ out.type = literal(FLAG_TYPES, o.type, `${path}.type`);
364
+ out.enabled = boolean(o.enabled, `${path}.enabled`);
365
+ out.variants = variants(o.variants, `${path}.variants`);
366
+ out.defaultVariant = string(o.defaultVariant, `${path}.defaultVariant`);
367
+ optional(out, o, "offVariant", path, string);
368
+ out.targeting = arrayOf(o.targeting, `${path}.targeting`, rule);
369
+ out.metadata = metadata(o.metadata, `${path}.metadata`);
370
+ out.createdAt = string(o.createdAt, `${path}.createdAt`);
371
+ out.updatedAt = string(o.updatedAt, `${path}.updatedAt`);
372
+ return out;
373
+ };
374
+ const segmentRule = (u, path) => {
375
+ const o = record(u, path);
376
+ const out = { id: string(o.id, `${path}.id`) };
377
+ optional(out, o, "description", path, string);
378
+ out.conditions = arrayOf(o.conditions, `${path}.conditions`, condition);
379
+ return out;
380
+ };
381
+ const decodeSegment = (u, path = "segment") => {
382
+ const o = record(u, path);
383
+ const out = { key: string(o.key, `${path}.key`) };
384
+ optional(out, o, "name", path, string);
385
+ optional(out, o, "description", path, string);
386
+ out.keyAttribute = string(o.keyAttribute, `${path}.keyAttribute`);
387
+ out.rules = arrayOf(o.rules, `${path}.rules`, segmentRule);
388
+ out.listMode = literal(LIST_MODES, o.listMode, `${path}.listMode`);
389
+ optional(out, o, "members", path, (v, p) => arrayOf(v, p, string));
390
+ out.version = number(o.version, `${path}.version`);
391
+ out.createdAt = string(o.createdAt, `${path}.createdAt`);
392
+ out.updatedAt = string(o.updatedAt, `${path}.updatedAt`);
393
+ return out;
394
+ };
395
+ /** An ordered config version: a non-negative safe integer. */
396
+ const isConfigVersion = (u) => typeof u === "number" && Number.isSafeInteger(u) && u >= 0;
397
+ /** `version`, `flags`, and `segments`: what a snapshot and a stream message share. */
398
+ const snapshotBody = (o, path) => {
399
+ const out = { version: string(o.version, `${path}.version`) };
400
+ out.flags = arrayOf(o.flags, `${path}.flags`, decodeFlag);
401
+ optional(out, o, "segments", path, (v, p) => arrayOf(v, p, decodeSegment));
402
+ return out;
403
+ };
404
+ /**
405
+ * A snapshot from `GET /sync/v1/flags` or a flag file. `configVersion` is
406
+ * checked strictly here, as the schema does; envelope keys a pulled file
407
+ * carries (`$schema`, `toggld`) are dropped.
408
+ */
409
+ const decodeSnapshot = (u) => {
410
+ const o = record(u, "snapshot");
411
+ const body = snapshotBody(o, "snapshot");
412
+ if (!has(o, "configVersion")) return body;
413
+ if (!isConfigVersion(o.configVersion)) fail("snapshot.configVersion", "a non-negative integer");
414
+ return {
415
+ version: body.version,
416
+ configVersion: o.configVersion,
417
+ ...withoutVersion(body)
418
+ };
419
+ };
420
+ const withoutVersion = ({ flags, segments }) => segments === void 0 ? { flags } : {
421
+ flags,
422
+ segments
423
+ };
424
+ /**
425
+ * A server→client sync stream message. Only `type: "snapshot"` exists today; an
426
+ * unknown type throws, and the caller skips it.
427
+ *
428
+ * The capabilities (`delivery`, `heartbeat`) describe the server, and an
429
+ * unusable `configVersion` only loses ordering, so all three are read leniently:
430
+ * a value a later server adds counts as best-effort / no heartbeat / unordered
431
+ * instead of rejecting the snapshot it rides on.
432
+ */
433
+ const decodeStreamMessage = (u) => {
434
+ const o = record(u, "message");
435
+ if (o.type !== "snapshot") fail("message.type", `"snapshot"`);
436
+ const body = snapshotBody(o, "message");
437
+ return {
438
+ snapshot: isConfigVersion(o.configVersion) ? {
439
+ version: body.version,
440
+ configVersion: o.configVersion,
441
+ ...withoutVersion(body)
442
+ } : body,
443
+ durable: o.delivery === "durable",
444
+ heartbeat: o.heartbeat === true
445
+ };
446
+ };
447
+ export { decodeOfrepSuccess as a, isRecord as c, discard as d, makeSend as f, withTimeout as h, decodeOfrepFailure as i, apiUrl as l, refusalMessage as m, decodeOfrepBulk as n, decodeSnapshot as o, readJson as p, decodeOfrepBulkFailure as r, decodeStreamMessage as s, WireError as t, describe as u };
package/package.json CHANGED
@@ -1,6 +1,79 @@
1
1
  {
2
2
  "name": "@toggld/js",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.9.0",
4
+ "description": "Toggld server SDK for JavaScript (Node.js, Bun, Deno): in-process flag evaluation kept current by a background sync (or read from a flag file), and a remote client for serverless and edge runtimes (Cloudflare Workers, AWS Lambda) via @toggld/js/serverless, with one bulk request per incoming request. Zero dependencies; optional native Effect API via @toggld/js/effect.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://toggld.io",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/toggld/toggld.git",
10
+ "directory": "packages/js"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/toggld/toggld/issues"
14
+ },
15
+ "keywords": [
16
+ "feature-flags",
17
+ "openfeature",
18
+ "toggld",
19
+ "effect",
20
+ "serverless",
21
+ "cloudflare-workers",
22
+ "aws-lambda",
23
+ "edge"
24
+ ],
25
+ "type": "module",
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "bin": {
30
+ "toggld": "./dist/cli.mjs"
31
+ },
32
+ "exports": {
33
+ "./package.json": "./package.json",
34
+ ".": {
35
+ "types": "./dist/index.d.mts",
36
+ "default": "./dist/index.mjs"
37
+ },
38
+ "./serverless": {
39
+ "types": "./dist/serverless.d.mts",
40
+ "default": "./dist/serverless.mjs"
41
+ },
42
+ "./serverless/effect": {
43
+ "types": "./dist/serverless-effect.d.mts",
44
+ "default": "./dist/serverless-effect.mjs"
45
+ },
46
+ "./effect": {
47
+ "types": "./dist/effect.d.mts",
48
+ "default": "./dist/effect.mjs"
49
+ }
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "peerDependencies": {
55
+ "effect": "^4.0.5"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "effect": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "effect": "4.0.6",
64
+ "miniflare": "5.20261023.1-beta",
65
+ "tsdown": "^0.23.2",
66
+ "typescript": "6.0.3",
67
+ "unrun": "^0.3.1",
68
+ "@toggld/engine": "0.1.0",
69
+ "@toggld/schema": "0.1.0",
70
+ "@toggld/server": "0.1.0",
71
+ "@toggld/spec": "0.1.0"
72
+ },
73
+ "scripts": {
74
+ "build": "tsdown",
75
+ "typecheck": "tsc --noEmit",
76
+ "test": "vitest run",
77
+ "test:watch": "vitest"
78
+ }
6
79
  }