Skip to content

Commit d2659b9

Browse files
committed
feat: update client
1 parent adbc133 commit d2659b9

2 files changed

Lines changed: 86 additions & 122 deletions

File tree

README.md

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ Part of the Replane project: [replane-dev/replane](https://github.com/replane-de
88
99
## Why it exists
1010

11-
You just need: given a token + config name -> get the value. This package does only that, well:
11+
You just need: given a token + config name -> get the value. This package does only that:
1212

1313
- Works in ESM and CJS (dual build)
1414
- Zero runtime deps (uses native `fetch` — bring a polyfill if your runtime lacks it)
1515
- Tiny bundle footprint
1616
- Strong TypeScript types
17-
- Resilient to server errors (returns your fallback and logs)
1817

1918
## Installation
2019

@@ -37,27 +36,26 @@ const client = createReplaneClient({
3736
});
3837

3938
// One-off fetch
40-
const featureFlag = await client.getConfigValue<boolean>({
41-
name: "new-onboarding",
42-
fallback: false,
43-
});
39+
40+
const featureFlag = await client
41+
.getConfigValue<boolean>("new-onboarding")
42+
// Ignore errors and use `false` if config is missing or fetch fails
43+
.catch(() => false);
4444

4545
// Typed example
4646
interface PasswordRequirements {
4747
minLength: number;
4848
requireSymbol: boolean;
4949
}
5050

51-
const passwordRequirements = await client.getConfigValue<PasswordRequirements>({
52-
name: "password-requirements",
53-
fallback: { minLength: 8, requireSymbol: false },
54-
});
51+
const passwordRequirements = await client
52+
.getConfigValue<PasswordRequirements>("password-requirements")
53+
.catch(() => ({ minLength: 8, requireSymbol: false }));
5554

56-
// Watching a config
57-
const billingEnabled = await client.watchConfigValue<boolean>({
58-
name: "billing-enabled",
59-
fallback: false,
60-
});
55+
// Watching a config (initial fetch must succeed)
56+
const billingEnabled = await client.watchConfigValue<boolean>(
57+
"billing-enabled"
58+
);
6159

6260
// Later, read the latest value
6361
if (billingEnabled.get()) {
@@ -84,31 +82,32 @@ Returns an object: `{ getConfigValue, watchConfigValue, close }`.
8482
- `baseUrl` (string) – API origin (no trailing slash needed).
8583
- `apiKey` (string) – API key for authorization. Required.
8684
- `fetchFn` (function) – custom fetch (e.g. `undici.fetch` or mocked fetch in tests).
87-
- `timeoutMs` (number) – abort the request after N ms. Default: 1000.
88-
- `logger` (`{ debug(...), info(...), warn(...), error(...) }`) – optional logger (defaults to `console`).
85+
- `timeoutMs` (number) – abort the request after N ms. Default: 5000.
8986

90-
### `client.getConfigValue({ name, fallback, ...overrides })`
87+
### `client.getConfigValue(name, overrides?)`
9188

9289
Parameters:
9390

9491
- `name` (string) – config name to fetch.
95-
- `fallback` (any) – value returned when request fails or response is invalid.
96-
- Overrides: `baseUrl`, `apiKey`, `fetchFn`, `timeoutMs`, `logger` – same semantics as in `createReplaneClient`.
92+
- Overrides: `baseUrl`, `apiKey`, `fetchFn`, `timeoutMs` – same semantics as in `createReplaneClient`.
9793

98-
Returns: the config value (or the provided fallback on failure).
94+
Returns: a promise resolving to the parsed JSON value.
9995

100-
Failures (non-2xx, network error, or invalid JSON) do not throw; the function logs via `logger.error(...)` and returns your `fallback`.
96+
Errors: throws on non-2xx responses (including 404 for missing configs), network errors, or invalid JSON. Catch `ReplaneError` to handle failures.
10197

102-
### `client.watchConfigValue({ name, fallback, ...overrides })`
98+
### `client.watchConfigValue(name, overrides?)`
10399

104-
Creates a lightweight watcher that refreshes the config value in the background (currently every 60 seconds). Useful for long‑lived processes wanting near‑real‑time updates without manually refetching.
100+
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.
105101

106102
Returns a promise resolving to an object: `{ get(): T, close(): void }`.
107103

108-
- `get()` – returns the most recent value (initially the provided fallback until the first successful fetch).
104+
- `get()` – returns the most recent value.
109105
- `close()` – stops the periodic refresh for just this watcher. Further calls to `get()` after `close()` throw.
110106

111-
Errors during refresh reuse the last known value.
107+
Notes:
108+
109+
- The initial fetch must succeed (it will throw on errors).
110+
- Subsequent periodic refreshes update the stored value on success.
112111

113112
#### Watcher lifecycle
114113

@@ -119,10 +118,7 @@ Errors during refresh reuse the last known value.
119118
Example:
120119

121120
```ts
122-
const billingEnabled = await client.watchConfigValue({
123-
name: "billing-enabled",
124-
fallback: false,
125-
});
121+
const billingEnabled = await client.watchConfigValue("billing-enabled");
126122
if (billingEnabled.get()) {
127123
// ...
128124
}
@@ -141,7 +137,7 @@ client.close();
141137

142138
### Errors
143139

144-
This SDK doesn't throw on request/response errors during `getConfigValue` or background refreshes in `watchConfigValue`. Instead, it logs (using the provided or default logger) and returns the provided `fallback` (or previous value for watchers).
140+
`getConfigValue` throws on non‑2xx HTTP responses (including 404), network errors, and invalid JSON. `watchConfigValue` uses `getConfigValue` for its initial fetch; handle errors accordingly with try/catch when creating a watcher. A `ReplaneError` is thrown for HTTP failures; other errors may be thrown for network/parse issues.
145141

146142
## Environment notes
147143

@@ -157,20 +153,13 @@ interface LayoutConfig {
157153
variant: "a" | "b";
158154
ttl: number;
159155
}
160-
const layout = await client.getConfigValue<LayoutConfig>({
161-
name: "layout",
162-
fallback: { variant: "a", ttl: 0 },
163-
});
156+
const layout = await client.getConfigValue<LayoutConfig>("layout");
164157
```
165158

166159
Timeout override:
167160

168161
```ts
169-
await client.getConfigValue({
170-
name: "slow-config",
171-
fallback: null,
172-
timeoutMs: 1500,
173-
});
162+
await client.getConfigValue("slow-config", { timeoutMs: 1500 });
174163
```
175164

176165
Custom fetch (tests):

src/index.ts

Lines changed: 57 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,9 @@ async function fetchWithTimeout(
5252
}
5353
}
5454

55-
export interface GetConfigRequest<T> extends Partial<ReplaneClientOptions> {
56-
/** Config name to fetch. */
57-
name: string;
55+
export interface GetConfigOptions<T> extends Partial<ReplaneClientOptions> {
5856
/** Fallback value if config is not found. */
59-
fallback: T;
57+
fallback?: T;
6058
}
6159

6260
export interface ConfigValueWatcher<T> {
@@ -68,15 +66,26 @@ export interface ConfigValueWatcher<T> {
6866

6967
export interface ReplaneClient {
7068
/** Fetch a config value by name. */
71-
getConfigValue<T = unknown>(req: GetConfigRequest<T>): Promise<T | undefined>;
69+
getConfigValue<T = unknown>(
70+
configName: string,
71+
options?: GetConfigOptions<T>
72+
): Promise<T | undefined>;
7273
/** Watch a config value by name. */
7374
watchConfigValue<T = unknown>(
74-
req: GetConfigRequest<T>
75+
configName: string,
76+
options?: GetConfigOptions<T>
7577
): Promise<ConfigValueWatcher<T>>;
7678
/** Close the client and clean up resources. */
7779
close(): void;
7880
}
7981

82+
export class ReplaneError extends Error {
83+
constructor(message: string) {
84+
super(message);
85+
this.name = "ReplaneError";
86+
}
87+
}
88+
8089
/**
8190
* Create a Replane client bound to an API key.
8291
* Usage:
@@ -89,36 +98,55 @@ export function createReplaneClient(
8998
if (!sdkOptions.apiKey) throw new Error("API key is required");
9099

91100
async function getConfigValue<T = unknown>(
92-
req: GetConfigRequest<T>
101+
configName: string,
102+
inputOptions: GetConfigOptions<T> = {}
93103
): Promise<T> {
94-
if (!req.name) throw new Error("config name is required");
95-
const finalOptions = combineOptions(sdkOptions, req);
96-
try {
97-
return await _getConfig<T>({
98-
configName: req.name,
99-
fallback: req.fallback,
100-
options: finalOptions,
101-
});
102-
} catch (err: unknown) {
103-
finalOptions.logger.error("ReplaneClient.getConfig error", err);
104-
return req.fallback;
104+
const combinedOptions = combineOptions(sdkOptions, inputOptions);
105+
const url = `${combinedOptions.baseUrl}/api/v1/configs/${encodeURIComponent(
106+
configName
107+
)}/value`;
108+
const res = await fetchWithTimeout(
109+
url,
110+
{
111+
method: "GET",
112+
headers: {
113+
Authorization: `Bearer ${combinedOptions.apiKey}`,
114+
Accept: "application/json, text/plain;q=0.9, */*;q=0.8",
115+
},
116+
},
117+
combinedOptions.timeoutMs,
118+
combinedOptions.fetchFn
119+
);
120+
121+
if (res.status === 404) {
122+
throw new ReplaneError(`Config not found: ${configName}`);
105123
}
124+
125+
let body: unknown = await res.json();
126+
127+
if (!res.ok) {
128+
throw new ReplaneError(
129+
`Error fetching config "${configName}": ${res.status} ${
130+
res.statusText
131+
}${typeof body === "string" ? ` - ${body}` : ""}`
132+
);
133+
}
134+
135+
return body as T;
106136
}
107137

108138
const watchers = new Set<ConfigValueWatcher<any>>();
109139

110140
async function watchConfigValue<T = unknown>(
111-
originalReq: GetConfigRequest<T>
141+
configName: string,
142+
originalOptions: GetConfigOptions<T> = {}
112143
): Promise<ConfigValueWatcher<T>> {
113-
const req = { ...originalReq };
114-
let currentWatcherValue: T = await getConfigValue<T>(req);
144+
const options = { ...originalOptions };
145+
let currentWatcherValue: T = await getConfigValue<T>(configName, options);
115146
let isWatcherClosed = false;
116147

117148
const intervalId = setInterval(async () => {
118-
currentWatcherValue = await getConfigValue<T>({
119-
...req,
120-
fallback: currentWatcherValue,
121-
});
149+
currentWatcherValue = await getConfigValue<T>(configName, options);
122150
}, 60_000);
123151

124152
const watcher: ConfigValueWatcher<T> = {
@@ -152,75 +180,22 @@ export function createReplaneClient(
152180
}
153181

154182
return {
155-
getConfigValue: async (req) => {
183+
getConfigValue: async (name, req) => {
156184
if (isClientClosed) {
157185
throw new Error("Replane client is closed");
158186
}
159-
return await getConfigValue(req);
187+
return await getConfigValue(name, req);
160188
},
161-
watchConfigValue: async (req) => {
189+
watchConfigValue: async (name, options) => {
162190
if (isClientClosed) {
163191
throw new Error("Replane client is closed");
164192
}
165-
return await watchConfigValue(req);
193+
return await watchConfigValue(name, options);
166194
},
167195
close,
168196
};
169197
}
170198

171-
async function _getConfig<T>(params: {
172-
configName: string;
173-
fallback: T;
174-
options: ReplaneFinalOptions;
175-
}): Promise<T> {
176-
const url = `${params.options.baseUrl}/api/v1/configs/${encodeURIComponent(
177-
params.configName
178-
)}/value`;
179-
const res = await fetchWithTimeout(
180-
url,
181-
{
182-
method: "GET",
183-
headers: {
184-
Authorization: `Bearer ${params.options.apiKey}`,
185-
Accept: "application/json, text/plain;q=0.9, */*;q=0.8",
186-
},
187-
},
188-
params.options.timeoutMs,
189-
params.options.fetchFn
190-
);
191-
192-
let body: unknown = null;
193-
const contentType = res.headers.get("content-type") || "";
194-
try {
195-
if (contentType.includes("application/json")) {
196-
body = await res.json();
197-
} else {
198-
body = await res.text();
199-
}
200-
} catch (e) {
201-
if (res.ok) {
202-
params.options.logger.error("ReplaneClient.getConfig invalid response", {
203-
name: params.configName,
204-
status: res.status,
205-
contentType,
206-
});
207-
return params.fallback;
208-
}
209-
}
210-
211-
if (!res.ok) {
212-
params.options.logger.error("ReplaneClient.getConfig error", {
213-
name: params.configName,
214-
status: res.status,
215-
body,
216-
});
217-
218-
return params.fallback;
219-
}
220-
221-
return body as T;
222-
}
223-
224199
function combineOptions(
225200
defaults: ReplaneClientOptions,
226201
overrides: Partial<ReplaneClientOptions>

0 commit comments

Comments
 (0)