@replanejs/sdk 0.5.6

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/dist/index.js ADDED
@@ -0,0 +1,477 @@
1
+ //#region src/index.ts
2
+ const SUPPORTED_REPLICATION_STREAM_RECORD_TYPES = Object.keys({
3
+ config_change: true,
4
+ init: true
5
+ });
6
+ /**
7
+ * FNV-1a 32-bit hash function
8
+ */
9
+ function fnv1a32(input) {
10
+ const encoder = new TextEncoder();
11
+ const bytes = encoder.encode(input);
12
+ let hash = 2166136261;
13
+ for (let i = 0; i < bytes.length; i++) {
14
+ hash ^= bytes[i];
15
+ hash = Math.imul(hash, 16777619) >>> 0;
16
+ }
17
+ return hash >>> 0;
18
+ }
19
+ /**
20
+ * Convert FNV-1a hash to [0, 1) for bucketing.
21
+ */
22
+ function fnv1a32ToUnit(input) {
23
+ const h = fnv1a32(input);
24
+ return h / 2 ** 32;
25
+ }
26
+ /**
27
+ * Evaluate config overrides based on context (client-side implementation)
28
+ * This is a simplified version without debug info
29
+ */
30
+ function evaluateOverrides(baseValue, overrides, context, logger) {
31
+ for (const override of overrides) {
32
+ let overrideResult = "matched";
33
+ const results = override.conditions.map((c) => evaluateCondition(c, context, logger));
34
+ if (results.some((r) => r === "not_matched")) overrideResult = "not_matched";
35
+ else if (results.some((r) => r === "unknown")) overrideResult = "unknown";
36
+ if (overrideResult === "matched") return override.value;
37
+ }
38
+ return baseValue;
39
+ }
40
+ /**
41
+ * Evaluate a single condition
42
+ */
43
+ function evaluateCondition(condition, context, logger) {
44
+ const operator = condition.operator;
45
+ if (operator === "and") {
46
+ const results = condition.conditions.map((c) => evaluateCondition(c, context, logger));
47
+ if (results.some((r) => r === "not_matched")) return "not_matched";
48
+ if (results.some((r) => r === "unknown")) return "unknown";
49
+ return "matched";
50
+ }
51
+ if (operator === "or") {
52
+ const results = condition.conditions.map((c) => evaluateCondition(c, context, logger));
53
+ if (results.some((r) => r === "matched")) return "matched";
54
+ if (results.some((r) => r === "unknown")) return "unknown";
55
+ return "not_matched";
56
+ }
57
+ if (operator === "not") {
58
+ const result = evaluateCondition(condition.condition, context, logger);
59
+ if (result === "matched") return "not_matched";
60
+ if (result === "not_matched") return "matched";
61
+ return "unknown";
62
+ }
63
+ if (operator === "segmentation") {
64
+ const contextValue$1 = context[condition.property];
65
+ if (contextValue$1 === void 0 || contextValue$1 === null) return "unknown";
66
+ const hashInput = String(contextValue$1) + condition.seed;
67
+ const unitValue = fnv1a32ToUnit(hashInput);
68
+ return unitValue >= condition.fromPercentage / 100 && unitValue < condition.toPercentage / 100 ? "matched" : "not_matched";
69
+ }
70
+ const property = condition.property;
71
+ const contextValue = context[property];
72
+ const expectedValue = condition.value;
73
+ if (contextValue === void 0) return "unknown";
74
+ const castedValue = castToContextType(expectedValue, contextValue);
75
+ switch (operator) {
76
+ case "equals": return contextValue === castedValue ? "matched" : "not_matched";
77
+ case "in":
78
+ if (!Array.isArray(castedValue)) return "unknown";
79
+ return castedValue.includes(contextValue) ? "matched" : "not_matched";
80
+ case "not_in":
81
+ if (!Array.isArray(castedValue)) return "unknown";
82
+ return !castedValue.includes(contextValue) ? "matched" : "not_matched";
83
+ case "less_than":
84
+ if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue < castedValue ? "matched" : "not_matched";
85
+ if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue < castedValue ? "matched" : "not_matched";
86
+ return "not_matched";
87
+ case "less_than_or_equal":
88
+ if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue <= castedValue ? "matched" : "not_matched";
89
+ if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue <= castedValue ? "matched" : "not_matched";
90
+ return "not_matched";
91
+ case "greater_than":
92
+ if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue > castedValue ? "matched" : "not_matched";
93
+ if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue > castedValue ? "matched" : "not_matched";
94
+ return "not_matched";
95
+ case "greater_than_or_equal":
96
+ if (typeof contextValue === "number" && typeof castedValue === "number") return contextValue >= castedValue ? "matched" : "not_matched";
97
+ if (typeof contextValue === "string" && typeof castedValue === "string") return contextValue >= castedValue ? "matched" : "not_matched";
98
+ return "not_matched";
99
+ default:
100
+ warnNever(operator, logger, `Unexpected operator: ${operator}`);
101
+ return "unknown";
102
+ }
103
+ }
104
+ function warnNever(value, logger, message) {
105
+ logger.warn(message, { value });
106
+ }
107
+ /**
108
+ * Cast expected value to match context value type
109
+ */
110
+ function castToContextType(expectedValue, contextValue) {
111
+ if (typeof contextValue === "number") {
112
+ if (typeof expectedValue === "string") {
113
+ const num = Number(expectedValue);
114
+ return isNaN(num) ? expectedValue : num;
115
+ }
116
+ return expectedValue;
117
+ }
118
+ if (typeof contextValue === "boolean") {
119
+ if (typeof expectedValue === "string") {
120
+ if (expectedValue === "true") return true;
121
+ if (expectedValue === "false") return false;
122
+ }
123
+ if (typeof expectedValue === "number") return expectedValue !== 0;
124
+ return expectedValue;
125
+ }
126
+ if (typeof contextValue === "string") {
127
+ if (typeof expectedValue === "number" || typeof expectedValue === "boolean") return String(expectedValue);
128
+ return expectedValue;
129
+ }
130
+ return expectedValue;
131
+ }
132
+ async function delay(ms) {
133
+ return new Promise((resolve) => setTimeout(resolve, ms));
134
+ }
135
+ async function retryDelay(averageDelay) {
136
+ const jitter = averageDelay / 5;
137
+ const delayMs = averageDelay + Math.random() * jitter - jitter / 2;
138
+ await delay(delayMs);
139
+ }
140
+ var ReplaneRemoteStorage = class {
141
+ closeController = new AbortController();
142
+ async *startReplicationStream(options) {
143
+ const { signal, cleanUpSignals } = combineAbortSignals([this.closeController.signal, options.signal]);
144
+ try {
145
+ let failedAttempts = 0;
146
+ while (!signal.aborted) try {
147
+ for await (const event of this.startReplicationStreamImpl({
148
+ ...options,
149
+ signal,
150
+ onConnect: () => {
151
+ failedAttempts = 0;
152
+ }
153
+ })) yield event;
154
+ } catch (error) {
155
+ failedAttempts++;
156
+ const retryDelayMs = Math.min(options.retryDelayMs * 2 ** (failedAttempts - 1), 1e4);
157
+ if (!signal.aborted) {
158
+ options.logger.error(`Failed to fetch project events, retrying in ${retryDelayMs}ms...`, error);
159
+ await retryDelay(retryDelayMs);
160
+ }
161
+ }
162
+ } finally {
163
+ cleanUpSignals();
164
+ }
165
+ }
166
+ async *startReplicationStreamImpl(options) {
167
+ const rawEvents = fetchSse({
168
+ fetchFn: options.fetchFn,
169
+ headers: {
170
+ Authorization: this.getAuthHeader(options),
171
+ "Content-Type": "application/json"
172
+ },
173
+ body: JSON.stringify(options.getBody()),
174
+ timeoutMs: options.requestTimeoutMs,
175
+ method: "POST",
176
+ signal: options.signal,
177
+ url: this.getApiEndpoint(`/sdk/v1/replication/stream`, options),
178
+ onConnect: options.onConnect
179
+ });
180
+ for await (const rawEvent of rawEvents) {
181
+ const event = JSON.parse(rawEvent);
182
+ if (typeof event === "object" && event !== null && "type" in event && typeof event.type === "string" && SUPPORTED_REPLICATION_STREAM_RECORD_TYPES.includes(event.type)) yield event;
183
+ }
184
+ }
185
+ close() {
186
+ this.closeController.abort();
187
+ }
188
+ getAuthHeader(options) {
189
+ return `Bearer ${options.sdkKey}`;
190
+ }
191
+ getApiEndpoint(path, options) {
192
+ return `${options.baseUrl}/api${path}`;
193
+ }
194
+ };
195
+ var ReplaneErrorCode = /* @__PURE__ */ function(ReplaneErrorCode$1) {
196
+ ReplaneErrorCode$1["NotFound"] = "not_found";
197
+ ReplaneErrorCode$1["Timeout"] = "timeout";
198
+ ReplaneErrorCode$1["NetworkError"] = "network_error";
199
+ ReplaneErrorCode$1["AuthError"] = "auth_error";
200
+ ReplaneErrorCode$1["Forbidden"] = "forbidden";
201
+ ReplaneErrorCode$1["ServerError"] = "server_error";
202
+ ReplaneErrorCode$1["ClientError"] = "client_error";
203
+ ReplaneErrorCode$1["Closed"] = "closed";
204
+ ReplaneErrorCode$1["NotInitialized"] = "not_initialized";
205
+ ReplaneErrorCode$1["Unknown"] = "unknown";
206
+ return ReplaneErrorCode$1;
207
+ }(ReplaneErrorCode || {});
208
+ var ReplaneError = class extends Error {
209
+ code;
210
+ constructor(params) {
211
+ super(params.message, { cause: params.cause });
212
+ this.name = "ReplaneError";
213
+ this.code = params.code;
214
+ }
215
+ };
216
+ /**
217
+ * Create a Replane client bound to an SDK key.
218
+ * Usage:
219
+ * const client = await createReplaneClient({ sdkKey: 'your-sdk-key', baseUrl: 'https://app.replane.dev' })
220
+ * const value = client.getConfig('my-config')
221
+ */
222
+ async function createReplaneClient(sdkOptions) {
223
+ const storage = new ReplaneRemoteStorage();
224
+ return await _createReplaneClient(toFinalOptions(sdkOptions), storage);
225
+ }
226
+ /**
227
+ * Create a Replane client that uses in-memory storage.
228
+ * Usage:
229
+ * const client = createInMemoryReplaneClient({ 'my-config': 123 })
230
+ * const value = client.getConfig('my-config') // 123
231
+ */
232
+ function createInMemoryReplaneClient(initialData) {
233
+ return {
234
+ getConfig: (configName) => {
235
+ const config = initialData[configName];
236
+ if (config === void 0) throw new ReplaneError({
237
+ message: `Config not found: ${String(configName)}`,
238
+ code: ReplaneErrorCode.NotFound
239
+ });
240
+ return config;
241
+ },
242
+ close: () => {}
243
+ };
244
+ }
245
+ async function _createReplaneClient(sdkOptions, storage) {
246
+ if (!sdkOptions.sdkKey) throw new Error("SDK key is required");
247
+ let configs = new Map(sdkOptions.fallbacks.map((config) => [config.name, config]));
248
+ const clientReady = new Deferred();
249
+ (async () => {
250
+ try {
251
+ const replicationStream = storage.startReplicationStream({
252
+ ...sdkOptions,
253
+ getBody: () => ({
254
+ currentConfigs: [...configs.values()].map((config) => ({
255
+ name: config.name,
256
+ overrides: config.overrides,
257
+ version: config.version,
258
+ value: config.value
259
+ })),
260
+ requiredConfigs: sdkOptions.requiredConfigs
261
+ })
262
+ });
263
+ for await (const event of replicationStream) if (event.type === "init") {
264
+ configs = new Map(event.configs.map((config) => [config.name, config]));
265
+ clientReady.resolve();
266
+ } else if (event.type === "config_change") configs.set(event.configName, {
267
+ name: event.configName,
268
+ overrides: event.overrides,
269
+ version: event.version,
270
+ value: event.value
271
+ });
272
+ else warnNever(event, sdkOptions.logger, "Replane: unknown event type in event stream");
273
+ } catch (error) {
274
+ sdkOptions.logger.error("Replane: error initializing client:", error);
275
+ clientReady.reject(error);
276
+ }
277
+ })();
278
+ function getConfig(configName, getConfigOptions = {}) {
279
+ const config = configs.get(String(configName));
280
+ if (config === void 0) throw new ReplaneError({
281
+ message: `Config not found: ${String(configName)}`,
282
+ code: ReplaneErrorCode.NotFound
283
+ });
284
+ try {
285
+ return evaluateOverrides(config.value, config.overrides, {
286
+ ...sdkOptions.context,
287
+ ...getConfigOptions?.context ?? {}
288
+ }, sdkOptions.logger);
289
+ } catch (error) {
290
+ sdkOptions.logger.error(`Replane: error evaluating overrides for config ${String(configName)}:`, error);
291
+ return config.value;
292
+ }
293
+ }
294
+ const close = () => storage.close();
295
+ const initializationTimeoutId = setTimeout(() => {
296
+ if (sdkOptions.fallbacks.length === 0) {
297
+ close();
298
+ clientReady.reject(new ReplaneError({
299
+ message: "Replane client initialization timed out",
300
+ code: ReplaneErrorCode.Timeout
301
+ }));
302
+ return;
303
+ }
304
+ const missingRequiredConfigs = [];
305
+ for (const requiredConfigName of sdkOptions.requiredConfigs) if (!configs.has(requiredConfigName)) missingRequiredConfigs.push(requiredConfigName);
306
+ if (missingRequiredConfigs.length > 0) {
307
+ close();
308
+ clientReady.reject(new ReplaneError({
309
+ message: `Required configs are missing: ${missingRequiredConfigs.join(", ")}`,
310
+ code: ReplaneErrorCode.NotFound
311
+ }));
312
+ return;
313
+ }
314
+ clientReady.resolve();
315
+ }, sdkOptions.sdkInitializationTimeoutMs);
316
+ clientReady.promise.then(() => clearTimeout(initializationTimeoutId));
317
+ await clientReady.promise;
318
+ return {
319
+ getConfig,
320
+ close
321
+ };
322
+ }
323
+ function toFinalOptions(defaults) {
324
+ return {
325
+ sdkKey: defaults.sdkKey,
326
+ baseUrl: defaults.baseUrl.replace(/\/+$/, ""),
327
+ fetchFn: defaults.fetchFn ?? globalThis.fetch.bind(globalThis),
328
+ requestTimeoutMs: defaults.requestTimeoutMs ?? 2e3,
329
+ sdkInitializationTimeoutMs: defaults.sdkInitializationTimeoutMs ?? 5e3,
330
+ logger: defaults.logger ?? console,
331
+ retryDelayMs: defaults.retryDelayMs ?? 200,
332
+ context: { ...defaults.context ?? {} },
333
+ requiredConfigs: Array.isArray(defaults.required) ? defaults.required.map((name) => String(name)) : Object.entries(defaults.required ?? {}).filter(([_, value]) => value !== void 0).map(([name]) => name),
334
+ fallbacks: Object.entries(defaults.fallbacks ?? {}).filter(([_, value]) => value !== void 0).map(([name, value]) => ({
335
+ name,
336
+ overrides: [],
337
+ version: -1,
338
+ value
339
+ }))
340
+ };
341
+ }
342
+ async function fetchWithTimeout(input, init, timeoutMs, fetchFn) {
343
+ if (!fetchFn) throw new Error("Global fetch is not available. Provide options.fetchFn.");
344
+ if (!timeoutMs) return fetchFn(input, init);
345
+ const timeoutController = new AbortController();
346
+ const t = setTimeout(() => timeoutController.abort(), timeoutMs);
347
+ const { signal } = combineAbortSignals([init.signal, timeoutController.signal]);
348
+ try {
349
+ return await fetchFn(input, {
350
+ ...init,
351
+ signal
352
+ });
353
+ } finally {
354
+ clearTimeout(t);
355
+ }
356
+ }
357
+ const SSE_DATA_PREFIX = "data:";
358
+ async function* fetchSse(params) {
359
+ const abortController = new AbortController();
360
+ const { signal, cleanUpSignals } = params.signal ? combineAbortSignals([params.signal, abortController.signal]) : {
361
+ signal: abortController.signal,
362
+ cleanUpSignals: () => {}
363
+ };
364
+ try {
365
+ const res = await fetchWithTimeout(params.url, {
366
+ method: params.method ?? "GET",
367
+ headers: {
368
+ Accept: "text/event-stream",
369
+ ...params.headers ?? {}
370
+ },
371
+ body: params.body,
372
+ signal
373
+ }, params.timeoutMs, params.fetchFn);
374
+ await ensureSuccessfulResponse(res, `SSE ${params.url}`);
375
+ const responseContentType = res.headers.get("content-type") ?? "";
376
+ if (!responseContentType.includes("text/event-stream")) throw new ReplaneError({
377
+ message: `Expected text/event-stream, got "${responseContentType}"`,
378
+ code: ReplaneErrorCode.ServerError
379
+ });
380
+ if (!res.body) throw new ReplaneError({
381
+ message: `Failed to fetch SSE ${params.url}: body is empty`,
382
+ code: ReplaneErrorCode.Unknown
383
+ });
384
+ if (params.onConnect) params.onConnect();
385
+ const decoded = res.body.pipeThrough(new TextDecoderStream());
386
+ const reader = decoded.getReader();
387
+ let buffer = "";
388
+ try {
389
+ while (true) {
390
+ const { value, done } = await reader.read();
391
+ if (done) break;
392
+ buffer += value;
393
+ const frames = buffer.split(/\r?\n\r?\n/);
394
+ buffer = frames.pop() ?? "";
395
+ for (const frame of frames) {
396
+ const dataLines = [];
397
+ for (const rawLine of frame.split(/\r?\n/)) {
398
+ if (!rawLine) continue;
399
+ if (rawLine.startsWith(":")) continue;
400
+ if (rawLine.startsWith(SSE_DATA_PREFIX)) {
401
+ const line = rawLine.slice(SSE_DATA_PREFIX.length).replace(/^\s/, "");
402
+ dataLines.push(line);
403
+ }
404
+ }
405
+ if (dataLines.length) {
406
+ const payload = dataLines.join("\n");
407
+ yield payload;
408
+ }
409
+ }
410
+ }
411
+ } finally {
412
+ try {
413
+ await reader.cancel();
414
+ } catch {}
415
+ abortController.abort();
416
+ }
417
+ } finally {
418
+ cleanUpSignals();
419
+ }
420
+ }
421
+ async function ensureSuccessfulResponse(response, message) {
422
+ if (response.status === 404) throw new ReplaneError({
423
+ message: `Not found: ${message}`,
424
+ code: ReplaneErrorCode.NotFound
425
+ });
426
+ if (response.status === 401) throw new ReplaneError({
427
+ message: `Unauthorized access: ${message}`,
428
+ code: ReplaneErrorCode.AuthError
429
+ });
430
+ if (response.status === 403) throw new ReplaneError({
431
+ message: `Forbidden access: ${message}`,
432
+ code: ReplaneErrorCode.Forbidden
433
+ });
434
+ if (!response.ok) {
435
+ let body;
436
+ try {
437
+ body = await response.text();
438
+ } catch {
439
+ body = "<unable to read response body>";
440
+ }
441
+ const code = response.status >= 500 ? ReplaneErrorCode.ServerError : response.status >= 400 ? ReplaneErrorCode.ClientError : ReplaneErrorCode.Unknown;
442
+ throw new ReplaneError({
443
+ message: `Fetch response isn't successful (${message}): ${response.status} ${response.statusText} - ${body}`,
444
+ code
445
+ });
446
+ }
447
+ }
448
+ function combineAbortSignals(signals) {
449
+ const controller = new AbortController();
450
+ const onAbort = () => {
451
+ controller.abort();
452
+ cleanUpSignals();
453
+ };
454
+ const cleanUpSignals = () => {
455
+ for (const s of signals) s?.removeEventListener("abort", onAbort);
456
+ };
457
+ for (const s of signals) s?.addEventListener("abort", onAbort, { once: true });
458
+ if (signals.some((s) => s?.aborted)) onAbort();
459
+ return {
460
+ signal: controller.signal,
461
+ cleanUpSignals
462
+ };
463
+ }
464
+ var Deferred = class {
465
+ promise;
466
+ resolve;
467
+ reject;
468
+ constructor() {
469
+ this.promise = new Promise((resolve, reject) => {
470
+ this.resolve = resolve;
471
+ this.reject = reject;
472
+ });
473
+ }
474
+ };
475
+
476
+ //#endregion
477
+ export { ReplaneError, createInMemoryReplaneClient, createReplaneClient };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@replanejs/sdk",
3
+ "version": "0.5.6",
4
+ "description": "Replane JavaScript SDK.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/replane-dev/replane-javascript#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/replane-dev/replane-javascript/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/replane-dev/replane-javascript.git"
14
+ },
15
+ "author": "Dmitry Tilyupo <tilyupo@gmail.com>",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "main": "./dist/index.cjs",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "require": "./dist/index.cjs"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "keywords": [
34
+ "replane",
35
+ "config",
36
+ "configuration",
37
+ "sdk",
38
+ "feature-flags"
39
+ ],
40
+ "sideEffects": false,
41
+ "scripts": {
42
+ "build": "pnpm run build:esm && pnpm run build:cjs",
43
+ "build:esm": "tsdown",
44
+ "build:cjs": "esbuild src/index.ts --platform=node --format=cjs --outfile=dist/index.cjs",
45
+ "dev": "tsdown --watch",
46
+ "test": "vitest run",
47
+ "typecheck": "tsc --noEmit",
48
+ "lint": "eslint src tests --max-warnings 0",
49
+ "lint:fix": "eslint src tests --fix",
50
+ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"README.md\"",
51
+ "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"README.md\"",
52
+ "release": "pnpm run build && bumpp && npm publish"
53
+ },
54
+ "devDependencies": {
55
+ "@eslint/js": "^9.18.0",
56
+ "@types/node": "^22.15.17",
57
+ "@typescript-eslint/eslint-plugin": "^8.20.0",
58
+ "@typescript-eslint/parser": "^8.20.0",
59
+ "async-channel": "^0.2.0",
60
+ "bumpp": "^10.1.0",
61
+ "esbuild": "^0.23.1",
62
+ "eslint": "^9.18.0",
63
+ "prettier": "^3.4.2",
64
+ "tsdown": "^0.11.9",
65
+ "typescript": "^5.8.3",
66
+ "typescript-eslint": "^8.20.0",
67
+ "vitest": "^3.1.3"
68
+ },
69
+ "packageManager": "pnpm@10.7.0+sha1.66453f13fbf9078d3db193718206a8d738afdbdb"
70
+ }