@@ -3,58 +3,65 @@ export interface ReplaneClientOptions {
33 baseUrl : string ;
44 /** Custom fetch implementation (useful for tests / polyfills). */
55 fetchFn ?: typeof fetch ;
6- /** Optional timeout in ms for the request. */
6+ /**
7+ * Optional timeout in ms for the request.
8+ * @default 1000
9+ */
710 timeoutMs ?: number ;
8- /** API key for authorization. */
11+ /** Project API key for authorization. */
912 apiKey : string ;
13+ /** Optional logger (defaults to console). */
14+ logger ?: ReplaneLogger ;
1015}
1116
12- export class ReplaneError extends Error {
13- status : number ;
14- body : unknown ;
15- constructor ( message : string , status : number , body : unknown ) {
16- super ( message ) ;
17- this . name = "ReplaneError" ;
18- this . status = status ;
19- this . body = body ;
20- }
17+ interface ReplaneFinalOptions {
18+ baseUrl : string ;
19+ fetchFn : typeof fetch ;
20+ timeoutMs : number ;
21+ apiKey : string ;
22+ logger : ReplaneLogger ;
2123}
2224
25+ export interface ReplaneLogger {
26+ debug ( ...args : any [ ] ) : void ;
27+ info ( ...args : any [ ] ) : void ;
28+ warn ( ...args : any [ ] ) : void ;
29+ error ( ...args : any [ ] ) : void ;
30+ }
31+
32+ const defaultLogger : ReplaneLogger = console ;
33+
2334/** Internal helper adding timeout support around fetch. */
2435// Use a looser 'any' for input to avoid depending on DOM lib types.
2536async function fetchWithTimeout (
2637 input : any ,
2738 init : RequestInit ,
28- timeoutMs ? : number ,
29- fetchFn ? : typeof fetch
39+ timeoutMs : number ,
40+ fetchFn : typeof fetch
3041) {
31- const fn = fetchFn ?? ( globalThis . fetch as typeof fetch | undefined ) ;
32- if ( ! fn ) {
42+ if ( ! fetchFn ) {
3343 throw new Error ( "Global fetch is not available. Provide options.fetchFn." ) ;
3444 }
35- if ( ! timeoutMs ) return fn ( input , init ) ;
45+ if ( ! timeoutMs ) return fetchFn ( input , init ) ;
3646 const controller = new AbortController ( ) ;
3747 const t = setTimeout ( ( ) => controller . abort ( ) , timeoutMs ) ;
3848 try {
39- return await fn ( input , { ...init , signal : controller . signal } ) ;
49+ return await fetchFn ( input , { ...init , signal : controller . signal } ) ;
4050 } finally {
4151 clearTimeout ( t ) ;
4252 }
4353}
4454
45- export interface GetConfigOptions extends Partial < ReplaneClientOptions > { }
46-
47- /** Shape of a successful config value response.
48- * The API might just return the raw value. We accept unknown to stay flexible.
49- */
50- export type ConfigValue < T = unknown > = T ;
55+ export interface GetConfigRequest < T > extends Partial < ReplaneClientOptions > {
56+ /** Config name to fetch. */
57+ name : string ;
58+ /** Fallback value if config is not found. */
59+ fallback : T ;
60+ }
5161
5262export interface ReplaneClient {
5363 /** Fetch a config value by name. */
54- getConfig < T = unknown > (
55- name : string ,
56- options ?: GetConfigOptions
57- ) : Promise < ConfigValue < T > > ;
64+ getConfig < T = unknown > ( req : GetConfigRequest < T > ) : Promise < T | undefined > ;
5865}
5966
6067/**
@@ -64,52 +71,90 @@ export interface ReplaneClient {
6471 * const value = await client.getConfig('my-config')
6572 */
6673export function createReplaneClient (
67- options : ReplaneClientOptions
74+ sdkOptions : ReplaneClientOptions
6875) : ReplaneClient {
69- if ( ! options . apiKey ) throw new Error ( "API key is required" ) ;
76+ if ( ! sdkOptions . apiKey ) throw new Error ( "API key is required" ) ;
7077
7178 return {
72- async getConfig < T = unknown > (
73- name : string ,
74- perCallOptions : GetConfigOptions = { }
75- ) : Promise < ConfigValue < T > > {
76- if ( ! name ) throw new Error ( "config name is required" ) ;
77- const finalOptions = { ...options , ...perCallOptions } ;
78- const finalBase = finalOptions . baseUrl . replace ( / \/ $ / , "" ) ;
79- const url = `${ finalBase } /api/v1/configs/${ encodeURIComponent (
80- name
81- ) } /value`;
82- const res = await fetchWithTimeout (
83- url ,
84- {
85- method : "GET" ,
86- headers : {
87- Authorization : `Bearer ${ finalOptions . apiKey } ` ,
88- Accept : "application/json, text/plain;q=0.9, */*;q=0.8" ,
89- } ,
90- } ,
91- perCallOptions . timeoutMs ?? finalOptions . timeoutMs ,
92- perCallOptions . fetchFn ?? finalOptions . fetchFn
93- ) ;
94-
95- let body : unknown = null ;
96- const contentType = res . headers . get ( "content-type" ) || "" ;
79+ async getConfig < T = unknown > ( req : GetConfigRequest < T > ) : Promise < T > {
80+ if ( ! req . name ) throw new Error ( "config name is required" ) ;
81+ const finalOptions = combineOptions ( sdkOptions , req ) ;
9782 try {
98- if ( contentType . includes ( "application/json" ) ) body = await res . json ( ) ;
99- else body = await res . text ( ) ;
100- } catch ( e ) {
101- // ignore body parse errors; body stays null
102- }
103-
104- if ( ! res . ok ) {
105- throw new ReplaneError (
106- `Failed to fetch config "${ name } " (status ${ res . status } )` ,
107- res . status ,
108- body
109- ) ;
83+ return await _getConfig < T > ( {
84+ configName : req . name ,
85+ fallback : req . fallback ,
86+ options : finalOptions ,
87+ } ) ;
88+ } catch ( err : unknown ) {
89+ finalOptions . logger . error ( "ReplaneClient.getConfig error" , err ) ;
90+ return req . fallback ;
11091 }
92+ } ,
93+ } ;
94+ }
11195
112- return body as T ;
96+ async function _getConfig < T > ( params : {
97+ configName : string ;
98+ fallback : T ;
99+ options : ReplaneFinalOptions ;
100+ } ) : Promise < T > {
101+ const url = `${ params . options . baseUrl } /api/v1/configs/${ encodeURIComponent (
102+ params . configName
103+ ) } /value`;
104+ const res = await fetchWithTimeout (
105+ url ,
106+ {
107+ method : "GET" ,
108+ headers : {
109+ Authorization : `Bearer ${ params . options . apiKey } ` ,
110+ Accept : "application/json, text/plain;q=0.9, */*;q=0.8" ,
111+ } ,
113112 } ,
113+ params . options . timeoutMs ,
114+ params . options . fetchFn
115+ ) ;
116+
117+ let body : unknown = null ;
118+ const contentType = res . headers . get ( "content-type" ) || "" ;
119+ try {
120+ if ( contentType . includes ( "application/json" ) ) {
121+ body = await res . json ( ) ;
122+ } else {
123+ body = await res . text ( ) ;
124+ }
125+ } catch ( e ) {
126+ if ( res . ok ) {
127+ params . options . logger . error ( "ReplaneClient.getConfig invalid response" , {
128+ name : params . configName ,
129+ status : res . status ,
130+ contentType,
131+ } ) ;
132+ return params . fallback ;
133+ }
134+ }
135+
136+ if ( ! res . ok ) {
137+ params . options . logger . error ( "ReplaneClient.getConfig error" , {
138+ name : params . configName ,
139+ status : res . status ,
140+ body,
141+ } ) ;
142+
143+ return params . fallback ;
144+ }
145+
146+ return body as T ;
147+ }
148+
149+ function combineOptions (
150+ defaults : ReplaneClientOptions ,
151+ overrides : Partial < ReplaneClientOptions >
152+ ) : ReplaneFinalOptions {
153+ return {
154+ apiKey : overrides . apiKey ?? defaults . apiKey ,
155+ baseUrl : ( overrides . baseUrl ?? defaults . baseUrl ) . replace ( / \/ + $ / , "" ) ,
156+ fetchFn : overrides . fetchFn ?? defaults . fetchFn ?? globalThis . fetch ,
157+ timeoutMs : overrides . timeoutMs ?? defaults . timeoutMs ?? 5000 ,
158+ logger : overrides . logger ?? defaults . logger ?? defaultLogger ,
114159 } ;
115160}
0 commit comments