Skip to content

Commit c387e02

Browse files
committed
feat: add in-memory client
1 parent 9ac4d5e commit c387e02

3 files changed

Lines changed: 338 additions & 2 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ Returns an object: `{ getConfigValue, watchConfigValue, close }`.
8383
- `apiKey` (string) – API key for authorization. Required.
8484
- `fetchFn` (function) – custom fetch (e.g. `undici.fetch` or mocked fetch in tests).
8585
- `timeoutMs` (number) – abort the request after N ms. Default: 5000.
86+
- `retries` (number) – number of retry attempts on failures (5xx or network errors). Default: 2.
87+
- `retryDelayMs` (number) – base delay between retries in ms (a small jitter is applied). Default: 100.
8688

8789
### `client.getConfigValue(name, overrides?)`
8890

@@ -95,6 +97,11 @@ Returns: a promise resolving to the parsed JSON value.
9597

9698
Errors: throws on non-2xx responses (including 404 for missing configs), network errors, or invalid JSON. Catch `ReplaneError` to handle failures.
9799

100+
Retry behavior:
101+
102+
- By default, transient failures (5xx responses or network errors) are retried up to `retries` times with a base delay of `retryDelayMs` between attempts.
103+
- You can override these per call via the `overrides` argument.
104+
98105
### `client.watchConfigValue(name, overrides?)`
99106

100107
Creates a lightweight watcher that refreshes the config value in the background (every 60 seconds). Useful for long‑lived processes wanting near‑real‑time updates without manually refetching.
@@ -126,6 +133,38 @@ if (billingEnabled.get()) {
126133
billingEnabled.close();
127134
```
128135

136+
### `createInMemoryReplaneClient(initialData)`
137+
138+
Creates a client backed by an in-memory store instead of making HTTP requests. Handy for unit tests or local development where you want deterministic config values without a server.
139+
140+
Parameters:
141+
142+
- `initialData` (object) – map of config name to value.
143+
144+
Returns the same client shape as `createReplaneClient` (`{ getConfigValue, watchConfigValue, close }`).
145+
146+
Notes:
147+
148+
- `getConfigValue(name)` resolves to the value from `initialData`.
149+
- If a name is missing, it throws a `ReplaneError` (`Config not found: <name>`).
150+
- `watchConfigValue` works as usual, refreshing every 60s (values remain whatever is in-memory).
151+
152+
Example:
153+
154+
```ts
155+
import { createInMemoryReplaneClient } from "replane-sdk";
156+
157+
const client = createInMemoryReplaneClient({
158+
"feature-a": true,
159+
"max-items": { value: 10, updatedAt: Date.now() },
160+
});
161+
162+
const enabled = await client.getConfigValue<boolean>("feature-a"); // true
163+
const watcher = await client.watchConfigValue<number>("max-items");
164+
watcher.get(); // { value: 10, updatedAt: ... }
165+
watcher.close();
166+
```
167+
129168
### `client.close()`
130169

131170
Gracefully shuts down the client, closing all active config watchers. Subsequent method calls will throw. Use this in environments where you manage resource lifecycles explicitly (e.g. shutting down a server or worker).

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ class ReplaneRemoteStorage implements ReplaneStorage {
127127
class ReplaneInMemoryStorage implements ReplaneStorage {
128128
private store: Map<string, any>;
129129

130-
constructor(initialData?: Record<string, any>) {
131-
this.store = new Map(Object.entries(initialData ?? {}));
130+
constructor(initialData: Record<string, any>) {
131+
this.store = new Map(Object.entries(initialData));
132132
}
133133

134134
async getConfigValue<T>(configName: string): Promise<T> {

tests/index.spec.ts

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import {
3+
createInMemoryReplaneClient,
4+
createReplaneClient,
5+
ReplaneError,
6+
type ReplaneClient,
7+
} from "../src";
8+
9+
type Fetch = (input: any, init?: RequestInit) => Promise<any>;
10+
11+
function responseOK(jsonValue: any) {
12+
return {
13+
ok: true,
14+
status: 200,
15+
statusText: "OK",
16+
json: async () => jsonValue,
17+
text: async () => JSON.stringify(jsonValue),
18+
};
19+
}
20+
21+
function responseError(status: number, statusText = "ERR", body = "error") {
22+
return {
23+
ok: false,
24+
status,
25+
statusText,
26+
json: async () => ({ error: body }),
27+
text: async () => String(body),
28+
};
29+
}
30+
31+
function makeSequenceFetch(
32+
responses: Array<() => any>
33+
): Fetch & { calls: number } {
34+
const fn: any = async () => {
35+
const idx = fn.calls++;
36+
const resFactory = responses[Math.min(idx, responses.length - 1)];
37+
const res = resFactory();
38+
if (res instanceof Error) throw res;
39+
return res;
40+
};
41+
fn.calls = 0;
42+
return fn;
43+
}
44+
45+
function makeTimeoutFetch(): Fetch & { calls: number } {
46+
const fn: any = (input: any, init?: RequestInit) => {
47+
fn.calls++;
48+
const signal = init?.signal as AbortSignal | undefined;
49+
return new Promise((_resolve, reject) => {
50+
if (signal?.aborted) {
51+
reject(new Error("aborted"));
52+
return;
53+
}
54+
const onAbort = () => reject(new Error("aborted"));
55+
signal?.addEventListener("abort", onAbort, { once: true });
56+
// Never resolve; will be aborted by timeout
57+
});
58+
};
59+
fn.calls = 0;
60+
return fn;
61+
}
62+
63+
function makeLogger() {
64+
return {
65+
debug: vi.fn(),
66+
info: vi.fn(),
67+
warn: vi.fn(),
68+
error: vi.fn(),
69+
};
70+
}
71+
72+
describe("ReplaneError", () => {
73+
it("has correct name", () => {
74+
const err = new ReplaneError("boom");
75+
expect(err.name).toBe("ReplaneError");
76+
expect(err.message).toBe("boom");
77+
});
78+
});
79+
80+
describe("In-memory client", () => {
81+
it("returns stored values", async () => {
82+
const client = createInMemoryReplaneClient({ a: 1, b: "x" });
83+
await expect(client.getConfigValue<number>("a")).resolves.toBe(1);
84+
await expect(client.getConfigValue<string>("b")).resolves.toBe("x");
85+
});
86+
87+
it("throws ReplaneError when config missing", async () => {
88+
const client = createInMemoryReplaneClient({});
89+
await expect(client.getConfigValue("missing")).rejects.toMatchObject({
90+
name: "ReplaneError",
91+
message: "Config not found: missing",
92+
});
93+
});
94+
95+
it("watcher returns initial value and then closes", async () => {
96+
const client = createInMemoryReplaneClient({ feature: true });
97+
const watcher = await client.watchConfigValue<boolean>("feature");
98+
expect(watcher.get()).toBe(true);
99+
watcher.close();
100+
expect(() => watcher.get()).toThrowError("Config value watcher is closed");
101+
});
102+
103+
it("client.close prevents further operations and closes watchers", async () => {
104+
const client = createInMemoryReplaneClient({ k: 42 });
105+
const watcher = await client.watchConfigValue<number>("k");
106+
expect(watcher.get()).toBe(42);
107+
client.close();
108+
expect(() => watcher.get()).toThrowError("Config value watcher is closed");
109+
await expect(client.getConfigValue("k")).rejects.toThrow(
110+
"Replane client is closed"
111+
);
112+
await expect(client.watchConfigValue("k")).rejects.toThrow(
113+
"Replane client is closed"
114+
);
115+
});
116+
});
117+
118+
describe("Remote client - fetch basics", () => {
119+
it("builds correct URL, headers, and returns JSON value", async () => {
120+
const fetch = vi.fn<Fetch>().mockResolvedValue(responseOK({ value: 123 }));
121+
const client = createReplaneClient({
122+
apiKey: "abc",
123+
baseUrl: "https://api.example.com/", // trailing slash should be trimmed
124+
fetchFn: fetch as unknown as typeof fetch,
125+
timeoutMs: 1000,
126+
});
127+
128+
const value = await client.getConfigValue<{ value: number }>(
129+
"my cfg with spaces"
130+
);
131+
expect(value).toEqual({ value: 123 });
132+
133+
expect(fetch).toHaveBeenCalledTimes(1);
134+
const [input, init] = fetch.mock.calls[0];
135+
expect(String(input)).toBe(
136+
"https://api.example.com/api/v1/configs/my%20cfg%20with%20spaces/value"
137+
);
138+
expect(init?.method).toBe("GET");
139+
// @ts-expect-error
140+
expect(init?.headers?.Authorization).toBe("Bearer abc");
141+
});
142+
143+
it("throws ReplaneError for 404", async () => {
144+
const fetch = vi
145+
.fn<Fetch>()
146+
.mockResolvedValue(responseError(404, "Not Found"));
147+
const client = createReplaneClient({
148+
apiKey: "key",
149+
baseUrl: "https://host",
150+
fetchFn: fetch as unknown as typeof fetch,
151+
retries: 0,
152+
});
153+
await expect(client.getConfigValue("unknown")).rejects.toMatchObject({
154+
name: "ReplaneError",
155+
message: "Config not found: unknown",
156+
});
157+
expect(fetch).toHaveBeenCalledTimes(1);
158+
});
159+
160+
it("retries on 5xx and eventually succeeds", async () => {
161+
vi.useFakeTimers();
162+
const logger = makeLogger();
163+
vi.spyOn(Math, "random").mockReturnValue(0.5); // deterministic retry delay = base
164+
const fetch = makeSequenceFetch([
165+
() => responseError(500, "ISE", "err1"),
166+
() => responseError(502, "BG", "err2"),
167+
() => responseOK({ ok: true }),
168+
]);
169+
const client = createReplaneClient({
170+
apiKey: "x",
171+
baseUrl: "https://h",
172+
fetchFn: fetch as unknown as typeof fetch,
173+
retries: 2,
174+
retryDelayMs: 200,
175+
logger,
176+
});
177+
178+
const p = client.getConfigValue("cfg");
179+
// Advance through two retry delays
180+
await vi.advanceTimersByTimeAsync(200 * 2);
181+
await expect(p).resolves.toEqual({ ok: true });
182+
expect(fetch.calls).toBe(3);
183+
expect(logger.warn).toHaveBeenCalledTimes(2);
184+
vi.useRealTimers();
185+
(Math.random as any).mockRestore?.();
186+
});
187+
188+
it("wraps network errors and retries", async () => {
189+
vi.useFakeTimers();
190+
const logger = makeLogger();
191+
vi.spyOn(Math, "random").mockReturnValue(0.5);
192+
const fetch = makeSequenceFetch([
193+
() => new Error("boom"),
194+
() => responseOK(7),
195+
]);
196+
const client = createReplaneClient({
197+
apiKey: "x",
198+
baseUrl: "https://h",
199+
fetchFn: fetch as unknown as typeof fetch,
200+
retries: 1,
201+
retryDelayMs: 100,
202+
logger,
203+
});
204+
const p = client.getConfigValue("cfg");
205+
await vi.advanceTimersByTimeAsync(100);
206+
await expect(p).resolves.toBe(7);
207+
expect(fetch.calls).toBe(2);
208+
expect(logger.warn).toHaveBeenCalledTimes(1);
209+
vi.useRealTimers();
210+
(Math.random as any).mockRestore?.();
211+
});
212+
213+
it("aborts long fetch by timeout and errors", async () => {
214+
vi.useFakeTimers();
215+
const fetch = makeTimeoutFetch();
216+
const client = createReplaneClient({
217+
apiKey: "k",
218+
baseUrl: "https://h",
219+
fetchFn: fetch as unknown as typeof fetch,
220+
timeoutMs: 100,
221+
retries: 0,
222+
});
223+
const p = client.getConfigValue("slow");
224+
// Attach rejection handler before advancing timers to avoid unhandled rejection
225+
const rejection = expect(p).rejects.toMatchObject({ name: "ReplaneError" });
226+
await vi.advanceTimersByTimeAsync(120);
227+
await rejection;
228+
expect(fetch.calls).toBe(1);
229+
vi.useRealTimers();
230+
});
231+
});
232+
233+
describe("Option merging and overrides", () => {
234+
it("per-call options override client defaults", async () => {
235+
const fetch = vi.fn<Fetch>().mockResolvedValue(responseOK("ok"));
236+
const client = createReplaneClient({
237+
apiKey: "A",
238+
baseUrl: "https://a.example",
239+
fetchFn: fetch as unknown as typeof fetch,
240+
});
241+
242+
await client.getConfigValue("x", {
243+
apiKey: "B",
244+
baseUrl: "https://b.example/",
245+
fetchFn: fetch as unknown as typeof fetch,
246+
});
247+
248+
const [input, init] = fetch.mock.calls[0];
249+
expect(String(input)).toBe("https://b.example/api/v1/configs/x/value");
250+
// @ts-expect-error
251+
expect(init?.headers?.Authorization).toBe("Bearer B");
252+
});
253+
254+
it("requires apiKey (throws on falsy)", () => {
255+
expect(() =>
256+
createReplaneClient({ apiKey: "", baseUrl: "https://h" })
257+
).toThrowError("API key is required");
258+
});
259+
});
260+
261+
describe("Watcher behavior (remote)", () => {
262+
beforeEach(() => {
263+
vi.useFakeTimers();
264+
});
265+
afterEach(() => {
266+
vi.useRealTimers();
267+
});
268+
269+
it("polls every 60s and updates value", async () => {
270+
const fetch = makeSequenceFetch([
271+
() => responseOK("v1"),
272+
() => responseOK("v2"),
273+
() => responseOK("v3"),
274+
]);
275+
const client = createReplaneClient({
276+
apiKey: "k",
277+
baseUrl: "https://h",
278+
fetchFn: fetch as unknown as typeof fetch,
279+
});
280+
const watcher = await client.watchConfigValue<string>("cfg");
281+
expect(fetch.calls).toBe(1);
282+
expect(watcher.get()).toBe("v1");
283+
284+
await vi.advanceTimersByTimeAsync(60_000);
285+
expect(fetch.calls).toBe(2);
286+
expect(watcher.get()).toBe("v2");
287+
288+
await vi.advanceTimersByTimeAsync(60_000);
289+
expect(fetch.calls).toBe(3);
290+
expect(watcher.get()).toBe("v3");
291+
292+
watcher.close();
293+
await vi.advanceTimersByTimeAsync(60_000);
294+
expect(fetch.calls).toBe(3); // no more polling
295+
expect(() => watcher.get()).toThrowError("Config value watcher is closed");
296+
});
297+
});

0 commit comments

Comments
 (0)