@configdirector/nuxt-sdk 1.7.0 → 1.8.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.
- package/README.md +34 -0
- package/dist/module.d.mts +26 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +12 -7
- package/dist/runtime/app/composables/useConfigDirectorClient.d.mts +1 -1
- package/dist/runtime/app/composables/useConfigDirectorClient.d.ts +1 -1
- package/dist/runtime/app/composables/useConfigDirectorClientHooks.d.mts +1 -1
- package/dist/runtime/app/composables/useConfigDirectorClientHooks.d.ts +1 -1
- package/dist/runtime/app/composables/useConfigDirectorContext.mjs +8 -3
- package/dist/runtime/nitro/plugin.d.mts +1 -1
- package/dist/runtime/nitro/plugin.d.ts +1 -1
- package/dist/runtime/nitro/plugin.mjs +149 -68
- package/dist/runtime/plugin.client.mjs +186 -84
- package/dist/runtime/plugin.server.mjs +8 -3
- package/dist/runtime/server/composables/useConfigDirectorClient.d.mts +1 -1
- package/dist/runtime/server/composables/useConfigDirectorClient.d.ts +1 -1
- package/dist/runtime/server/composables/useConfigDirectorServerHooks.d.mts +1 -1
- package/dist/runtime/server/composables/useConfigDirectorServerHooks.d.ts +1 -1
- package/dist/runtime/{types-C54FjC1D.d.mts → types-Bgx2-3dY.d.mts} +1 -0
- package/dist/runtime/{types-BPvGEzJY.d.mts → types-CvmjergQ.d.mts} +6 -2
- package/dist/testing.d.mts +352 -0
- package/dist/testing.mjs +1645 -0
- package/package.json +11 -4
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,1645 @@
|
|
|
1
|
+
//#region ../js-client-core/src/Emitter.ts
|
|
2
|
+
var Emitter = class {
|
|
3
|
+
handlerMap = /* @__PURE__ */ new Map();
|
|
4
|
+
on(name, handler) {
|
|
5
|
+
const handlers = this.handlerMap.get(name);
|
|
6
|
+
if (handlers) handlers.push(handler);
|
|
7
|
+
else this.handlerMap.set(name, [handler]);
|
|
8
|
+
}
|
|
9
|
+
once(name, handler) {
|
|
10
|
+
const self = this;
|
|
11
|
+
function onceHandler(payload) {
|
|
12
|
+
self.off(name, onceHandler);
|
|
13
|
+
handler.apply(self, payload);
|
|
14
|
+
}
|
|
15
|
+
this.on(name, onceHandler);
|
|
16
|
+
}
|
|
17
|
+
off(name, handler) {
|
|
18
|
+
const handlers = this.handlerMap.get(name);
|
|
19
|
+
if (!handlers) return;
|
|
20
|
+
if (handler) {
|
|
21
|
+
const listenerIndex = handlers.indexOf(handler);
|
|
22
|
+
if (listenerIndex >= 0) handlers.splice(listenerIndex, 1);
|
|
23
|
+
} else this.handlerMap.set(name, []);
|
|
24
|
+
}
|
|
25
|
+
clear() {
|
|
26
|
+
this.handlerMap.clear();
|
|
27
|
+
}
|
|
28
|
+
emit(name, payload) {
|
|
29
|
+
const handlers = this.handlerMap.get(name);
|
|
30
|
+
if (!handlers) return;
|
|
31
|
+
handlers.slice().map((h) => h(payload));
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region ../shared/src/errors.ts
|
|
36
|
+
var ConfigDirectorConnectionError = class ConfigDirectorConnectionError extends Error {
|
|
37
|
+
name = "ConfigDirectorConnectionError";
|
|
38
|
+
status;
|
|
39
|
+
constructor(message, status) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.status = status;
|
|
42
|
+
Object.setPrototypeOf(this, ConfigDirectorConnectionError.prototype);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var ConfigDirectorValidationError = class ConfigDirectorValidationError extends Error {
|
|
46
|
+
name = "ConfigDirectorValidationError";
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
Object.setPrototypeOf(this, ConfigDirectorValidationError.prototype);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region ../eventsource/src/errors.ts
|
|
54
|
+
var StreamClosedError = class StreamClosedError extends Error {
|
|
55
|
+
name = "StreamClosedError";
|
|
56
|
+
constructor(message) {
|
|
57
|
+
super(message);
|
|
58
|
+
Object.setPrototypeOf(this, StreamClosedError.prototype);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var MissingResponseBodyError = class MissingResponseBodyError extends Error {
|
|
62
|
+
name = "MissingResponseBodyError";
|
|
63
|
+
constructor(message) {
|
|
64
|
+
super(message);
|
|
65
|
+
Object.setPrototypeOf(this, MissingResponseBodyError.prototype);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var InvalidOptionError = class InvalidOptionError extends Error {
|
|
69
|
+
name = "InvalidOptionError";
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(message);
|
|
72
|
+
Object.setPrototypeOf(this, InvalidOptionError.prototype);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var ValueOutOfRangeError = class ValueOutOfRangeError extends Error {
|
|
76
|
+
name = "ValueOutOfRangeError";
|
|
77
|
+
constructor(message) {
|
|
78
|
+
super(message);
|
|
79
|
+
Object.setPrototypeOf(this, ValueOutOfRangeError.prototype);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region ../eventsource/src/types.ts
|
|
84
|
+
let ReadyState = /* @__PURE__ */ function(ReadyState) {
|
|
85
|
+
ReadyState["OPEN"] = "open";
|
|
86
|
+
ReadyState["CLOSED"] = "closed";
|
|
87
|
+
ReadyState["CONNECTING"] = "connecting";
|
|
88
|
+
return ReadyState;
|
|
89
|
+
}({});
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region ../eventsource/src/EventSourceParser.ts
|
|
92
|
+
var EventSourceParser = class {
|
|
93
|
+
isFirstChunk = true;
|
|
94
|
+
bufferedInput = "";
|
|
95
|
+
currentEvent = { data: "" };
|
|
96
|
+
lastEventId;
|
|
97
|
+
onEvent;
|
|
98
|
+
onRetry;
|
|
99
|
+
onComment;
|
|
100
|
+
constructor(options) {
|
|
101
|
+
this.currentEvent = { data: "" };
|
|
102
|
+
this.onEvent = options.onEvent ?? (() => {});
|
|
103
|
+
this.onRetry = options.onRetry;
|
|
104
|
+
this.onComment = options.onComment;
|
|
105
|
+
}
|
|
106
|
+
parse(chunk) {
|
|
107
|
+
let input = chunk;
|
|
108
|
+
if (this.isFirstChunk) {
|
|
109
|
+
this.isFirstChunk = false;
|
|
110
|
+
if (input.startsWith("")) input = input.slice(1);
|
|
111
|
+
else if (input.startsWith("")) input = input.slice(3);
|
|
112
|
+
}
|
|
113
|
+
const lines = this.scanLines(this.bufferedInput + input);
|
|
114
|
+
for (const line of lines) this.dispatchLine(line);
|
|
115
|
+
}
|
|
116
|
+
finish() {
|
|
117
|
+
this.bufferedInput = "";
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Scans `text` character by character to extract complete lines, recognizing
|
|
121
|
+
* CR, LF, and CRLF as line terminators per the SSE spec. Any unterminated
|
|
122
|
+
* trailing text is buffered and prepended to the next chunk.
|
|
123
|
+
*/
|
|
124
|
+
scanLines(text) {
|
|
125
|
+
const lines = [];
|
|
126
|
+
let lineStart = 0;
|
|
127
|
+
let i = 0;
|
|
128
|
+
while (i < text.length) {
|
|
129
|
+
const ch = text[i];
|
|
130
|
+
if (ch === "\r" || ch === "\n") {
|
|
131
|
+
lines.push(text.slice(lineStart, i));
|
|
132
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
133
|
+
i++;
|
|
134
|
+
lineStart = i;
|
|
135
|
+
} else i++;
|
|
136
|
+
}
|
|
137
|
+
this.bufferedInput = text.slice(lineStart);
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
dispatchLine(line) {
|
|
141
|
+
if (line.startsWith(":")) {
|
|
142
|
+
this.onComment?.(this.extractValue(line, 1));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (line === "") {
|
|
146
|
+
this.emitEvent();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.applyField(this.parseField(line));
|
|
150
|
+
}
|
|
151
|
+
emitEvent() {
|
|
152
|
+
const data = this.currentEvent.data.endsWith("\n") ? this.currentEvent.data.slice(0, -1) : this.currentEvent.data;
|
|
153
|
+
if (data !== "") this.onEvent({
|
|
154
|
+
...this.currentEvent,
|
|
155
|
+
id: this.lastEventId,
|
|
156
|
+
data
|
|
157
|
+
});
|
|
158
|
+
this.currentEvent = { data: "" };
|
|
159
|
+
}
|
|
160
|
+
applyField(parsed) {
|
|
161
|
+
switch (parsed.field) {
|
|
162
|
+
case "event":
|
|
163
|
+
this.currentEvent.type = parsed.value;
|
|
164
|
+
break;
|
|
165
|
+
case "data":
|
|
166
|
+
this.currentEvent.data += parsed.value + "\n";
|
|
167
|
+
break;
|
|
168
|
+
case "id":
|
|
169
|
+
if (!parsed.value.includes("\0")) this.lastEventId = parsed.value;
|
|
170
|
+
break;
|
|
171
|
+
case "retry":
|
|
172
|
+
if (/^\d+$/.test(parsed.value)) this.onRetry?.(Number.parseInt(parsed.value, 10));
|
|
173
|
+
break;
|
|
174
|
+
default: break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
parseField(line) {
|
|
178
|
+
const colon = line.indexOf(":");
|
|
179
|
+
if (colon === -1) return {
|
|
180
|
+
field: line,
|
|
181
|
+
value: ""
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
field: line.slice(0, colon),
|
|
185
|
+
value: this.extractValue(line, colon + 1)
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Returns the value portion of a line, stripping a single leading space per spec. */
|
|
189
|
+
extractValue(line, from) {
|
|
190
|
+
return line[from] === " " ? line.slice(from + 1) : line.slice(from);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region ../eventsource/src/EventSourceClient.ts
|
|
195
|
+
var EventSourceClient = class {
|
|
196
|
+
DEFAULT_SHOULD_RECONNECT = () => true;
|
|
197
|
+
DEFAULT_CALCULATE_RECONNECT_DELAY = () => this.serverReconnectionTime;
|
|
198
|
+
url;
|
|
199
|
+
headers;
|
|
200
|
+
body;
|
|
201
|
+
requestOptions;
|
|
202
|
+
lastEventId;
|
|
203
|
+
reconnectAttempt = 0;
|
|
204
|
+
abortController = new AbortController();
|
|
205
|
+
reconnectTimeout;
|
|
206
|
+
_readyState = ReadyState.CLOSED;
|
|
207
|
+
serverReconnectionTime = 2e3;
|
|
208
|
+
_fetch;
|
|
209
|
+
_onError;
|
|
210
|
+
_onMessage;
|
|
211
|
+
_onComment;
|
|
212
|
+
_onConnect;
|
|
213
|
+
_onDisconnect;
|
|
214
|
+
_shouldReconnect;
|
|
215
|
+
_calculateReconnectDelay;
|
|
216
|
+
constructor(options) {
|
|
217
|
+
this.url = options.url.toString();
|
|
218
|
+
this.lastEventId = options.lastEventId;
|
|
219
|
+
this.headers = {
|
|
220
|
+
Accept: "text/event-stream",
|
|
221
|
+
...options.headers
|
|
222
|
+
};
|
|
223
|
+
const body = options.body;
|
|
224
|
+
this.body = typeof body === "function" ? body : () => body;
|
|
225
|
+
this.requestOptions = {
|
|
226
|
+
method: options.method ?? "GET",
|
|
227
|
+
mode: options.mode,
|
|
228
|
+
credentials: options.credentials,
|
|
229
|
+
redirect: options.redirect,
|
|
230
|
+
referrer: options.referrer,
|
|
231
|
+
referrerPolicy: options.referrerPolicy
|
|
232
|
+
};
|
|
233
|
+
this._fetch = options.fetch ?? ((url, init) => fetch(url, init));
|
|
234
|
+
this._onError = this.resolveCallback(options.onError, () => {}, "onError");
|
|
235
|
+
this._onConnect = this.resolveCallback(options.onConnect, () => {}, "onConnect");
|
|
236
|
+
this._onDisconnect = this.resolveCallback(options.onDisconnect, () => {}, "onDisconnect");
|
|
237
|
+
this._onMessage = this.resolveCallback(options.onMessage, () => {}, "onMessage");
|
|
238
|
+
this._onComment = this.resolveCallback(options.onComment, () => {}, "onComment");
|
|
239
|
+
this._shouldReconnect = this.resolveCallback(options.shouldReconnect, this.DEFAULT_SHOULD_RECONNECT, "shouldReconnect");
|
|
240
|
+
this._calculateReconnectDelay = this.resolveCallback(options.calculateReconnectDelay, this.DEFAULT_CALCULATE_RECONNECT_DELAY, "calculateReconnectDelay");
|
|
241
|
+
}
|
|
242
|
+
set onError(value) {
|
|
243
|
+
this._onError = this.resolveCallback(value, () => {}, "onError");
|
|
244
|
+
}
|
|
245
|
+
set onMessage(value) {
|
|
246
|
+
this._onMessage = this.resolveCallback(value, () => {}, "onMessage");
|
|
247
|
+
}
|
|
248
|
+
set onComment(value) {
|
|
249
|
+
this._onComment = this.resolveCallback(value, () => {}, "onComment");
|
|
250
|
+
}
|
|
251
|
+
set onConnect(value) {
|
|
252
|
+
this._onConnect = this.resolveCallback(value, () => {}, "onConnect");
|
|
253
|
+
}
|
|
254
|
+
set onDisconnect(value) {
|
|
255
|
+
this._onDisconnect = this.resolveCallback(value, () => {}, "onDisconnect");
|
|
256
|
+
}
|
|
257
|
+
set shouldReconnect(value) {
|
|
258
|
+
this._shouldReconnect = this.resolveCallback(value, this.DEFAULT_SHOULD_RECONNECT, "shouldReconnect");
|
|
259
|
+
}
|
|
260
|
+
set calculateReconnectDelay(value) {
|
|
261
|
+
this._calculateReconnectDelay = this.resolveCallback(value, this.DEFAULT_CALCULATE_RECONNECT_DELAY, "shouldReconnect");
|
|
262
|
+
}
|
|
263
|
+
get readyState() {
|
|
264
|
+
return this._readyState;
|
|
265
|
+
}
|
|
266
|
+
connect() {
|
|
267
|
+
if (this._readyState !== ReadyState.CLOSED) return;
|
|
268
|
+
this.reconnectAttempt = 0;
|
|
269
|
+
this.initiateConnection();
|
|
270
|
+
}
|
|
271
|
+
close() {
|
|
272
|
+
clearTimeout(this.reconnectTimeout);
|
|
273
|
+
this._readyState = ReadyState.CLOSED;
|
|
274
|
+
this.abortController.abort();
|
|
275
|
+
}
|
|
276
|
+
initiateConnection() {
|
|
277
|
+
this.abortController = new AbortController();
|
|
278
|
+
this._readyState = ReadyState.CONNECTING;
|
|
279
|
+
this._fetch(this.url, {
|
|
280
|
+
...this.requestOptions,
|
|
281
|
+
body: this.body(),
|
|
282
|
+
headers: this.buildRequestHeaders(),
|
|
283
|
+
signal: this.abortController.signal,
|
|
284
|
+
cache: "no-store"
|
|
285
|
+
}).then((response) => this.handleFetchResponse(response)).catch((error) => {
|
|
286
|
+
if (error.name === "AbortError" || this.abortController.signal.aborted) {
|
|
287
|
+
this.close();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this._onError(error);
|
|
291
|
+
this.scheduleReconnect({ error });
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
async handleFetchResponse(response) {
|
|
295
|
+
const { body, status } = response;
|
|
296
|
+
if (status === 204) {
|
|
297
|
+
this.disconnected();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (status >= 400) {
|
|
301
|
+
this.scheduleReconnect({ status });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!body) throw new MissingResponseBodyError("The server response did not have a body");
|
|
305
|
+
this._readyState = ReadyState.OPEN;
|
|
306
|
+
this._onConnect();
|
|
307
|
+
this.reconnectAttempt = 0;
|
|
308
|
+
const parser = new EventSourceParser({
|
|
309
|
+
onEvent: (event) => {
|
|
310
|
+
if (typeof event.id === "string") this.lastEventId = event.id;
|
|
311
|
+
this._onMessage(event);
|
|
312
|
+
},
|
|
313
|
+
onComment: (comment) => this._onComment(comment),
|
|
314
|
+
onRetry: (retryDelay) => this.serverReconnectionTime = retryDelay
|
|
315
|
+
});
|
|
316
|
+
await this.readResponseStream(body, parser);
|
|
317
|
+
this.scheduleReconnect({
|
|
318
|
+
status,
|
|
319
|
+
error: new StreamClosedError("The server response stream was closed")
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
async readResponseStream(body, parser) {
|
|
323
|
+
const reader = body.getReader();
|
|
324
|
+
const decoder = new TextDecoder();
|
|
325
|
+
let done = false;
|
|
326
|
+
while (!done) {
|
|
327
|
+
const result = await reader.read();
|
|
328
|
+
if (result.value) parser.parse(decoder.decode(result.value, { stream: true }));
|
|
329
|
+
done = result.done;
|
|
330
|
+
}
|
|
331
|
+
parser.finish();
|
|
332
|
+
}
|
|
333
|
+
disconnected() {
|
|
334
|
+
clearTimeout(this.reconnectTimeout);
|
|
335
|
+
this._readyState = ReadyState.CLOSED;
|
|
336
|
+
this._onDisconnect();
|
|
337
|
+
}
|
|
338
|
+
buildRequestHeaders() {
|
|
339
|
+
const lastEventIdHeader = this.lastEventId ? { "Last-Event-ID": this.lastEventId } : void 0;
|
|
340
|
+
return {
|
|
341
|
+
...this.headers,
|
|
342
|
+
...lastEventIdHeader
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
scheduleReconnect(state) {
|
|
346
|
+
if (this.abortController.signal.aborted) return;
|
|
347
|
+
clearTimeout(this.reconnectTimeout);
|
|
348
|
+
this.reconnectAttempt += 1;
|
|
349
|
+
const reconnectionState = {
|
|
350
|
+
attempt: this.reconnectAttempt,
|
|
351
|
+
status: state.status,
|
|
352
|
+
error: state.error,
|
|
353
|
+
serverReconnectionTime: this.serverReconnectionTime
|
|
354
|
+
};
|
|
355
|
+
if (!this._shouldReconnect(reconnectionState)) {
|
|
356
|
+
this.disconnected();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this._readyState = ReadyState.CONNECTING;
|
|
360
|
+
let delay = this._calculateReconnectDelay(reconnectionState);
|
|
361
|
+
if (!Number.isFinite(delay) || delay < 1 || delay > 1e3 * 60 * 60) {
|
|
362
|
+
this._onError(new ValueOutOfRangeError(`The calculated reconnect delay is out of range: ${delay}. Defaulting to ${this.serverReconnectionTime}`));
|
|
363
|
+
delay = this.serverReconnectionTime;
|
|
364
|
+
}
|
|
365
|
+
this.reconnectTimeout = setTimeout(() => this.initiateConnection(), delay);
|
|
366
|
+
}
|
|
367
|
+
resolveCallback(provided, fallback, optionName) {
|
|
368
|
+
if (provided == null) return fallback;
|
|
369
|
+
if (typeof provided !== "function") throw new InvalidOptionError(`${optionName} must be a function`);
|
|
370
|
+
return provided;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region ../eventsource/src/index.ts
|
|
375
|
+
const createEventSourceClient = (options) => {
|
|
376
|
+
return new EventSourceClient(options);
|
|
377
|
+
};
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region ../js-client-core/src/StreamingTransport.ts
|
|
380
|
+
var StreamingTransport = class {
|
|
381
|
+
logger;
|
|
382
|
+
eventSource;
|
|
383
|
+
eventEmitter = new Emitter();
|
|
384
|
+
url;
|
|
385
|
+
timeoutTimer;
|
|
386
|
+
constructor(options) {
|
|
387
|
+
this.options = options;
|
|
388
|
+
this.options = options;
|
|
389
|
+
this.logger = options.logger;
|
|
390
|
+
this.url = options.resolveUrl("client/sse/v1", options.baseUrl);
|
|
391
|
+
}
|
|
392
|
+
async connect(context, timeout) {
|
|
393
|
+
if (this.eventSource) this.close();
|
|
394
|
+
const eventSourcePromise = new Promise((resolve, reject) => {
|
|
395
|
+
this.eventSource = createEventSourceClient({
|
|
396
|
+
url: this.url,
|
|
397
|
+
method: "POST",
|
|
398
|
+
headers: { "Content-Type": "application/json" },
|
|
399
|
+
fetch: this.options.fetch,
|
|
400
|
+
body: JSON.stringify({
|
|
401
|
+
givenContext: context,
|
|
402
|
+
metaContext: this.options.metaContext,
|
|
403
|
+
clientSdkKey: this.options.clientSdkKey,
|
|
404
|
+
instanceId: this.options.instanceId
|
|
405
|
+
}),
|
|
406
|
+
onMessage: ({ data }) => {
|
|
407
|
+
this.dispatchMessage(data);
|
|
408
|
+
},
|
|
409
|
+
onConnect: () => {
|
|
410
|
+
this.logger.debug("[StreamingTransport] Connected");
|
|
411
|
+
resolve(this);
|
|
412
|
+
},
|
|
413
|
+
shouldReconnect: (state) => {
|
|
414
|
+
const reconnect = !this.isStatusFatal(state.status);
|
|
415
|
+
if (!reconnect) {
|
|
416
|
+
const error = this.prepareFatalError(state.status, state.error);
|
|
417
|
+
this.eventEmitter.emit("connectionError", error);
|
|
418
|
+
reject(error);
|
|
419
|
+
}
|
|
420
|
+
return reconnect;
|
|
421
|
+
},
|
|
422
|
+
calculateReconnectDelay: (state) => {
|
|
423
|
+
const delay = this.options.connectionRetryDelay(state.attempt);
|
|
424
|
+
const logMessage = `[StreamingTransport] Scheduling reconnect attempt #${state.attempt} in ${delay}ms.`;
|
|
425
|
+
if (state.attempt <= 5) this.logger.info(logMessage);
|
|
426
|
+
else this.logger.warn(logMessage);
|
|
427
|
+
return delay;
|
|
428
|
+
},
|
|
429
|
+
onError: (error) => {
|
|
430
|
+
this.logger.debug("[StreamingTransport] Error: ", error);
|
|
431
|
+
},
|
|
432
|
+
onDisconnect: () => {
|
|
433
|
+
this.logger.debug("[StreamingTransport] Disconnected");
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
this.eventSource.connect();
|
|
437
|
+
});
|
|
438
|
+
clearTimeout(this.timeoutTimer);
|
|
439
|
+
this.timeoutTimer = void 0;
|
|
440
|
+
return Promise.race([eventSourcePromise, new Promise((resolve) => {
|
|
441
|
+
this.timeoutTimer = setTimeout(() => resolve(this), timeout);
|
|
442
|
+
})]).finally(() => clearTimeout(this.timeoutTimer));
|
|
443
|
+
}
|
|
444
|
+
dispatchMessage(data) {
|
|
445
|
+
try {
|
|
446
|
+
const json = JSON.parse(data);
|
|
447
|
+
this.eventEmitter.emit("configSetReceived", json);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
this.logger.error("[StreamingTransport] Error parsing and dispatching config data update: ", error);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
prepareFatalError(responseStatus, error) {
|
|
453
|
+
const status = responseStatus ?? 0;
|
|
454
|
+
return new ConfigDirectorConnectionError(`${`Connection failed with status: ${responseStatus ?? "unknown"}.`}${error ? ` Error: ${error}` : ""}. This is an unrecoverable error, will not attempt to reconnect.`, status);
|
|
455
|
+
}
|
|
456
|
+
isStatusFatal(status) {
|
|
457
|
+
return !!status && status >= 400 && status < 500 && status != 429;
|
|
458
|
+
}
|
|
459
|
+
on(name, handler) {
|
|
460
|
+
this.eventEmitter.on(name, handler);
|
|
461
|
+
}
|
|
462
|
+
off(name, handler) {
|
|
463
|
+
this.eventEmitter.off(name, handler);
|
|
464
|
+
}
|
|
465
|
+
clear() {
|
|
466
|
+
this.eventEmitter.clear();
|
|
467
|
+
}
|
|
468
|
+
close() {
|
|
469
|
+
this.eventSource?.close();
|
|
470
|
+
clearTimeout(this.timeoutTimer);
|
|
471
|
+
}
|
|
472
|
+
dispose() {
|
|
473
|
+
this.close();
|
|
474
|
+
this.clear();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region ../shared/src/value-parser.ts
|
|
479
|
+
const getRequestedType = (defaultValue) => {
|
|
480
|
+
const baseType = typeof defaultValue;
|
|
481
|
+
if (baseType === "object") try {
|
|
482
|
+
return defaultValue.constructor?.name ?? baseType;
|
|
483
|
+
} catch {
|
|
484
|
+
return baseType;
|
|
485
|
+
}
|
|
486
|
+
else if (baseType === "function") try {
|
|
487
|
+
const functionName = defaultValue.name;
|
|
488
|
+
return functionName ? `function: ${functionName}` : baseType;
|
|
489
|
+
} catch {
|
|
490
|
+
return baseType;
|
|
491
|
+
}
|
|
492
|
+
return baseType;
|
|
493
|
+
};
|
|
494
|
+
const isNumericNativeType = (requestedType) => {
|
|
495
|
+
return requestedType === "number" || requestedType === "bigint";
|
|
496
|
+
};
|
|
497
|
+
const configTypesNotReadableAsString = new Set([
|
|
498
|
+
"boolean",
|
|
499
|
+
"integer",
|
|
500
|
+
"float"
|
|
501
|
+
]);
|
|
502
|
+
const parseConfigValue = (configState, defaultValue) => {
|
|
503
|
+
const value = configState.value;
|
|
504
|
+
const requestedType = getRequestedType(defaultValue);
|
|
505
|
+
if (value == null || value === "") return {
|
|
506
|
+
parsedValue: defaultValue,
|
|
507
|
+
parsedValueId: void 0,
|
|
508
|
+
requestedType,
|
|
509
|
+
usedDefault: true,
|
|
510
|
+
reason: "value-missing"
|
|
511
|
+
};
|
|
512
|
+
if (configState.type === "json") return parseJson(value, defaultValue, requestedType, configState.valueId);
|
|
513
|
+
if (typeof defaultValue === "string") {
|
|
514
|
+
if (configTypesNotReadableAsString.has(configState.type)) return {
|
|
515
|
+
parsedValue: defaultValue,
|
|
516
|
+
parsedValueId: void 0,
|
|
517
|
+
requestedType,
|
|
518
|
+
usedDefault: true,
|
|
519
|
+
reason: "type-mismatch"
|
|
520
|
+
};
|
|
521
|
+
return {
|
|
522
|
+
parsedValue: value,
|
|
523
|
+
parsedValueId: configState.valueId,
|
|
524
|
+
requestedType,
|
|
525
|
+
usedDefault: false,
|
|
526
|
+
reason: "found-match"
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (typeof defaultValue === "boolean" && (configState.type === "boolean" || configState.type === "string" || configState.type === "enum")) {
|
|
530
|
+
const boolValue = parseConfigBoolean(value);
|
|
531
|
+
const hasBoolean = typeof boolValue === "boolean";
|
|
532
|
+
return {
|
|
533
|
+
parsedValue: hasBoolean ? boolValue : defaultValue,
|
|
534
|
+
parsedValueId: hasBoolean ? configState.valueId : void 0,
|
|
535
|
+
requestedType,
|
|
536
|
+
usedDefault: !hasBoolean,
|
|
537
|
+
reason: hasBoolean ? "found-match" : "invalid-boolean"
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (isNumericNativeType(typeof defaultValue) && configState.type === "integer") {
|
|
541
|
+
const numValue = parseConfigInteger(value);
|
|
542
|
+
const hasNumber = typeof numValue === "number";
|
|
543
|
+
return {
|
|
544
|
+
parsedValue: hasNumber ? numValue : defaultValue,
|
|
545
|
+
parsedValueId: hasNumber ? configState.valueId : void 0,
|
|
546
|
+
requestedType,
|
|
547
|
+
usedDefault: !hasNumber,
|
|
548
|
+
reason: hasNumber ? "found-match" : "invalid-number"
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (isNumericNativeType(typeof defaultValue) && (configState.type === "float" || configState.type === "enum" || configState.type === "string")) {
|
|
552
|
+
const numValue = parseConfigFloat(value);
|
|
553
|
+
const hasNumber = typeof numValue === "number";
|
|
554
|
+
return {
|
|
555
|
+
parsedValue: hasNumber ? numValue : defaultValue,
|
|
556
|
+
parsedValueId: hasNumber ? configState.valueId : void 0,
|
|
557
|
+
requestedType,
|
|
558
|
+
usedDefault: !hasNumber,
|
|
559
|
+
reason: hasNumber ? "found-match" : "invalid-number"
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
parsedValue: defaultValue,
|
|
564
|
+
parsedValueId: void 0,
|
|
565
|
+
requestedType,
|
|
566
|
+
usedDefault: true,
|
|
567
|
+
reason: "type-mismatch"
|
|
568
|
+
};
|
|
569
|
+
};
|
|
570
|
+
const parseConfigBoolean = (value) => {
|
|
571
|
+
if (!value) return;
|
|
572
|
+
const lowerValue = value.toLowerCase();
|
|
573
|
+
if (lowerValue != "true" && lowerValue != "false") return;
|
|
574
|
+
return lowerValue === "true";
|
|
575
|
+
};
|
|
576
|
+
const parseConfigInteger = (value) => {
|
|
577
|
+
const num = parseStrictNumber(value);
|
|
578
|
+
return num == null ? void 0 : Math.trunc(num);
|
|
579
|
+
};
|
|
580
|
+
const parseConfigFloat = (value) => {
|
|
581
|
+
return parseStrictNumber(value);
|
|
582
|
+
};
|
|
583
|
+
const parseStrictNumber = (value) => {
|
|
584
|
+
if (!value || !isDecimalCharacters(value)) return;
|
|
585
|
+
const num = Number(value);
|
|
586
|
+
if (isNaN(num) || !isFinite(num)) return;
|
|
587
|
+
return num;
|
|
588
|
+
};
|
|
589
|
+
const isDecimalCharacters = (value) => {
|
|
590
|
+
for (const character of value) if (!(character >= "0" && character <= "9") && !"+-.eE".includes(character)) return false;
|
|
591
|
+
return true;
|
|
592
|
+
};
|
|
593
|
+
const parseJson = (jsonString, defaultValue, requestedType, valueId) => {
|
|
594
|
+
if (typeof defaultValue === "string") return {
|
|
595
|
+
parsedValue: jsonString,
|
|
596
|
+
parsedValueId: valueId,
|
|
597
|
+
requestedType,
|
|
598
|
+
usedDefault: false,
|
|
599
|
+
reason: "found-match"
|
|
600
|
+
};
|
|
601
|
+
try {
|
|
602
|
+
const parsed = JSON.parse(jsonString);
|
|
603
|
+
if (!isJsonTypeCompatible(parsed, defaultValue)) return {
|
|
604
|
+
parsedValue: defaultValue,
|
|
605
|
+
parsedValueId: void 0,
|
|
606
|
+
requestedType,
|
|
607
|
+
usedDefault: true,
|
|
608
|
+
reason: "type-mismatch"
|
|
609
|
+
};
|
|
610
|
+
return {
|
|
611
|
+
parsedValue: parsed,
|
|
612
|
+
parsedValueId: valueId,
|
|
613
|
+
requestedType,
|
|
614
|
+
usedDefault: false,
|
|
615
|
+
reason: "found-match"
|
|
616
|
+
};
|
|
617
|
+
} catch {
|
|
618
|
+
return {
|
|
619
|
+
parsedValue: defaultValue,
|
|
620
|
+
parsedValueId: void 0,
|
|
621
|
+
requestedType,
|
|
622
|
+
usedDefault: true,
|
|
623
|
+
reason: "invalid-json"
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const isJsonTypeCompatible = (parsed, defaultValue) => {
|
|
628
|
+
if (parsed === null) return false;
|
|
629
|
+
const defaultType = typeof defaultValue;
|
|
630
|
+
if (defaultType === "bigint") return typeof parsed === "number";
|
|
631
|
+
return typeof parsed === defaultType;
|
|
632
|
+
};
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region ../shared/src/logger.ts
|
|
635
|
+
const LEVELS = {
|
|
636
|
+
off: -1,
|
|
637
|
+
error: 0,
|
|
638
|
+
warn: 1,
|
|
639
|
+
info: 2,
|
|
640
|
+
debug: 3
|
|
641
|
+
};
|
|
642
|
+
const buildDateFormatter = () => {
|
|
643
|
+
const baseOptions = {
|
|
644
|
+
year: "numeric",
|
|
645
|
+
month: "2-digit",
|
|
646
|
+
day: "2-digit",
|
|
647
|
+
hour: "2-digit",
|
|
648
|
+
minute: "2-digit",
|
|
649
|
+
second: "2-digit",
|
|
650
|
+
timeZoneName: "short"
|
|
651
|
+
};
|
|
652
|
+
try {
|
|
653
|
+
return new Intl.DateTimeFormat(void 0, {
|
|
654
|
+
...baseOptions,
|
|
655
|
+
fractionalSecondDigits: 3
|
|
656
|
+
});
|
|
657
|
+
} catch {
|
|
658
|
+
return new Intl.DateTimeFormat(void 0, baseOptions);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
var DefaultConsoleLogger = class {
|
|
662
|
+
dateFormatter;
|
|
663
|
+
constructor(level, decorator) {
|
|
664
|
+
this.level = level;
|
|
665
|
+
this.decorator = decorator;
|
|
666
|
+
this.level = level;
|
|
667
|
+
this.decorator = decorator;
|
|
668
|
+
this.dateFormatter = buildDateFormatter();
|
|
669
|
+
}
|
|
670
|
+
debug(message, ...args) {
|
|
671
|
+
this.log(console.debug, "debug", message, ...args);
|
|
672
|
+
}
|
|
673
|
+
info(message, ...args) {
|
|
674
|
+
this.log(console.info, "info", message, ...args);
|
|
675
|
+
}
|
|
676
|
+
warn(message, ...args) {
|
|
677
|
+
this.log(console.warn, "warn", message, ...args);
|
|
678
|
+
}
|
|
679
|
+
error(message, ...args) {
|
|
680
|
+
this.log(console.error, "error", message, ...args);
|
|
681
|
+
}
|
|
682
|
+
log(loggerFunction, level, message, ...args) {
|
|
683
|
+
if (LEVELS[this.level] >= LEVELS[level]) loggerFunction(`[${this.dateFormatter.format(/* @__PURE__ */ new Date())}] ${this.decorator?.decorateMessage(message)}`, ...args);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
//#endregion
|
|
687
|
+
//#region ../js-client-core/src/logger.ts
|
|
688
|
+
var LogMessageDecorator = class {
|
|
689
|
+
decorateMessage(message) {
|
|
690
|
+
return `[ConfigDirector:js-client-sdk] ${message}`;
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
const createDefaultLogger = (level, messageDecorator) => {
|
|
694
|
+
return new DefaultConsoleLogger(level ?? "warn", messageDecorator ?? new LogMessageDecorator());
|
|
695
|
+
};
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region ../shared/src/url.ts
|
|
698
|
+
const defaultUrlFactory = (input, base) => new URL(input, base?.toString());
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region ../shared/src/transport/pollingInterval.ts
|
|
701
|
+
const resolvePollingInterval = (configuredSeconds, defaults) => {
|
|
702
|
+
if (configuredSeconds === void 0) return {
|
|
703
|
+
seconds: defaults.defaultSeconds,
|
|
704
|
+
warning: void 0
|
|
705
|
+
};
|
|
706
|
+
if (configuredSeconds >= defaults.minimumSeconds) return {
|
|
707
|
+
seconds: configuredSeconds,
|
|
708
|
+
warning: void 0
|
|
709
|
+
};
|
|
710
|
+
return {
|
|
711
|
+
seconds: defaults.minimumSeconds,
|
|
712
|
+
warning: `pollingInterval of ${configuredSeconds} seconds is below the minimum of ${defaults.minimumSeconds} seconds. Using ${defaults.minimumSeconds} seconds.`
|
|
713
|
+
};
|
|
714
|
+
};
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region ../shared/src/instance-id.ts
|
|
717
|
+
const generateInstanceId = () => {
|
|
718
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
719
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (placeholder) => {
|
|
720
|
+
const random = Math.random() * 16 | 0;
|
|
721
|
+
return (placeholder === "x" ? random : random & 3 | 8).toString(16);
|
|
722
|
+
});
|
|
723
|
+
};
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region ../js-client-core/src/instance-id.ts
|
|
726
|
+
const STORAGE_KEY = "configdirector-sdk:instance-id";
|
|
727
|
+
const RECORD_VERSION = 1;
|
|
728
|
+
const MAX_AGE_MS = 360 * 60 * 1e3;
|
|
729
|
+
const CHECKSUM_SALT = "configdirector.instanceId.v1";
|
|
730
|
+
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
731
|
+
const fnv1a = (input, offsetBasis) => {
|
|
732
|
+
let hash = offsetBasis;
|
|
733
|
+
for (let i = 0; i < input.length; i++) {
|
|
734
|
+
hash ^= input.charCodeAt(i);
|
|
735
|
+
hash = Math.imul(hash, 16777619);
|
|
736
|
+
}
|
|
737
|
+
return hash >>> 0;
|
|
738
|
+
};
|
|
739
|
+
const checksumFor = (id, createdAt) => {
|
|
740
|
+
const payload = `${CHECKSUM_SALT}|${id}|${createdAt}`;
|
|
741
|
+
const low = fnv1a(payload, 2166136261);
|
|
742
|
+
return fnv1a(`${low}|${payload}`, 16777619).toString(16).padStart(8, "0") + low.toString(16).padStart(8, "0");
|
|
743
|
+
};
|
|
744
|
+
const getLocalStorage = () => {
|
|
745
|
+
try {
|
|
746
|
+
return globalThis.localStorage ?? void 0;
|
|
747
|
+
} catch {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
const isUsableRecord = (record) => {
|
|
752
|
+
if (!record || typeof record !== "object") return false;
|
|
753
|
+
const { v, id, createdAt, checksum } = record;
|
|
754
|
+
if (v !== RECORD_VERSION || typeof id !== "string" || typeof checksum !== "string") return false;
|
|
755
|
+
if (typeof createdAt !== "number" || !Number.isInteger(createdAt) || createdAt <= 0) return false;
|
|
756
|
+
if (!UUID_V4_PATTERN.test(id)) return false;
|
|
757
|
+
return checksum === checksumFor(id, createdAt);
|
|
758
|
+
};
|
|
759
|
+
const readStoredInstanceId = (storage, logger) => {
|
|
760
|
+
let raw;
|
|
761
|
+
try {
|
|
762
|
+
raw = storage.getItem(STORAGE_KEY);
|
|
763
|
+
} catch (error) {
|
|
764
|
+
logger?.debug("[ConfigDirectorClient] Could not read the stored instance id, generating a new one", error);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (!raw) return;
|
|
768
|
+
let record;
|
|
769
|
+
try {
|
|
770
|
+
record = JSON.parse(raw);
|
|
771
|
+
} catch {
|
|
772
|
+
logger?.debug("[ConfigDirectorClient] The stored instance id is not valid JSON, generating a new one");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (!isUsableRecord(record)) {
|
|
776
|
+
logger?.debug("[ConfigDirectorClient] The stored instance id failed validation, generating a new one");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const age = Date.now() - record.createdAt;
|
|
780
|
+
if (age < 0 || age >= MAX_AGE_MS) {
|
|
781
|
+
logger?.debug("[ConfigDirectorClient] The stored instance id has expired, generating a new one");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
return record.id;
|
|
785
|
+
};
|
|
786
|
+
const storeInstanceId = (storage, id, logger) => {
|
|
787
|
+
const createdAt = Date.now();
|
|
788
|
+
const record = {
|
|
789
|
+
v: RECORD_VERSION,
|
|
790
|
+
id,
|
|
791
|
+
createdAt,
|
|
792
|
+
checksum: checksumFor(id, createdAt)
|
|
793
|
+
};
|
|
794
|
+
try {
|
|
795
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
796
|
+
} catch (error) {
|
|
797
|
+
logger?.debug("[ConfigDirectorClient] Could not persist the instance id", error);
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
const resolveInstanceId = (logger) => {
|
|
801
|
+
const storage = getLocalStorage();
|
|
802
|
+
if (!storage) return generateInstanceId();
|
|
803
|
+
const storedInstanceId = readStoredInstanceId(storage, logger);
|
|
804
|
+
if (storedInstanceId) return storedInstanceId;
|
|
805
|
+
const instanceId = generateInstanceId();
|
|
806
|
+
storeInstanceId(storage, instanceId, logger);
|
|
807
|
+
return instanceId;
|
|
808
|
+
};
|
|
809
|
+
//#endregion
|
|
810
|
+
//#region ../shared/src/transport/AbstractPollingTransport.ts
|
|
811
|
+
var AbstractPollingTransport = class {
|
|
812
|
+
fatalError = false;
|
|
813
|
+
pollingInterval;
|
|
814
|
+
clearPollingInterval() {
|
|
815
|
+
clearInterval(this.pollingInterval);
|
|
816
|
+
}
|
|
817
|
+
schedulePollingInterval(callback) {
|
|
818
|
+
clearInterval(this.pollingInterval);
|
|
819
|
+
if (this.pollingIntervalSeconds <= 0) return;
|
|
820
|
+
this.pollingInterval = setInterval(callback, this.pollingIntervalSeconds * 1e3);
|
|
821
|
+
}
|
|
822
|
+
async handleNonOkResponse(response) {
|
|
823
|
+
if (!response.ok) if (this.isStatusFatal(response.status)) {
|
|
824
|
+
this.fatalError = true;
|
|
825
|
+
this.close();
|
|
826
|
+
throw this.prepareFatalResponseStatusError(response.status, await response.text());
|
|
827
|
+
} else throw new ConfigDirectorConnectionError(`Connection failed with status: ${response.status}`, response.status);
|
|
828
|
+
}
|
|
829
|
+
handleFetchError(fetchError) {
|
|
830
|
+
if (this.isFetchErrorFatal(fetchError)) {
|
|
831
|
+
this.fatalError = true;
|
|
832
|
+
this.close();
|
|
833
|
+
throw new ConfigDirectorConnectionError(`Connection failed with fatal error: ${fetchError}. This is an unrecoverable error, retry attempts will be ignored.`);
|
|
834
|
+
} else if (fetchError instanceof SyntaxError) throw new ConfigDirectorConnectionError(`Failed to parse the response from the server: ${fetchError}`);
|
|
835
|
+
else throw new ConfigDirectorConnectionError(`Connection failed with error: ${fetchError}.`);
|
|
836
|
+
}
|
|
837
|
+
prepareFatalResponseStatusError(responseStatus, errorBody) {
|
|
838
|
+
const status = responseStatus ?? 0;
|
|
839
|
+
return new ConfigDirectorConnectionError(`${`Connection failed with status: ${responseStatus ?? "unknown"}`}${(errorBody?.trim()?.length ?? 0) > 0 ? ` (${errorBody})` : ""}. This is an unrecoverable error, retry attempts will be ignored.`, status);
|
|
840
|
+
}
|
|
841
|
+
isStatusFatal(status) {
|
|
842
|
+
return !!status && status >= 400 && status < 500 && status != 429;
|
|
843
|
+
}
|
|
844
|
+
isFetchErrorFatal(fetchError) {
|
|
845
|
+
return fetchError?.name === "NotAllowedError";
|
|
846
|
+
}
|
|
847
|
+
get isConnected() {
|
|
848
|
+
return this.pollingInterval !== void 0;
|
|
849
|
+
}
|
|
850
|
+
close() {
|
|
851
|
+
clearInterval(this.pollingInterval);
|
|
852
|
+
this.pollingInterval = void 0;
|
|
853
|
+
}
|
|
854
|
+
dispose() {
|
|
855
|
+
this.close();
|
|
856
|
+
this.clear();
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region ../shared/src/fetchWithTimeout.ts
|
|
861
|
+
const fetchWithTimeout = async (timeout, resource, options, logger) => {
|
|
862
|
+
const abortController = new AbortController();
|
|
863
|
+
const abortTimeoutId = setTimeout(() => {
|
|
864
|
+
logger.debug("[fetchWithTimeout] Reached timeout, aborting request");
|
|
865
|
+
abortController.abort();
|
|
866
|
+
}, timeout);
|
|
867
|
+
try {
|
|
868
|
+
const response = await fetch(typeof resource === "string" || resource instanceof Request ? resource : resource.toString(), {
|
|
869
|
+
...options,
|
|
870
|
+
signal: abortController.signal
|
|
871
|
+
});
|
|
872
|
+
clearTimeout(abortTimeoutId);
|
|
873
|
+
return response;
|
|
874
|
+
} catch (error) {
|
|
875
|
+
logger.warn("[fetchWithTimeout] Fetch error: ", error);
|
|
876
|
+
clearTimeout(abortTimeoutId);
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region ../js-client-core/src/PollingTransport.ts
|
|
882
|
+
var PollingTransport = class extends AbstractPollingTransport {
|
|
883
|
+
pollingIntervalSeconds;
|
|
884
|
+
logger;
|
|
885
|
+
eventEmitter = new Emitter();
|
|
886
|
+
url;
|
|
887
|
+
lastUpdateTimestamp;
|
|
888
|
+
constructor(options) {
|
|
889
|
+
super();
|
|
890
|
+
this.options = options;
|
|
891
|
+
this.options = options;
|
|
892
|
+
this.logger = options.logger;
|
|
893
|
+
this.url = options.resolveUrl("client/polling/v1", options.baseUrl);
|
|
894
|
+
this.pollingIntervalSeconds = options.pollingInterval ?? 60;
|
|
895
|
+
}
|
|
896
|
+
async connect(context, timeout) {
|
|
897
|
+
this.clearPollingInterval();
|
|
898
|
+
try {
|
|
899
|
+
await this.fetchConfigs(context, timeout);
|
|
900
|
+
} finally {
|
|
901
|
+
if (!this.fatalError) this.schedulePollingInterval(() => {
|
|
902
|
+
this.fetchConfigs(context, timeout).catch((error) => {
|
|
903
|
+
this.logger.error("[PollingTransport] Error during polling:", error);
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return this;
|
|
908
|
+
}
|
|
909
|
+
async fetchConfigs(context, timeout) {
|
|
910
|
+
if (this.fatalError) {
|
|
911
|
+
this.logger.warn("[PollingTransport] There was a prior unrecoverable error. Ignoring attempt to reconnect.");
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
try {
|
|
915
|
+
const response = await fetchWithTimeout(timeout, this.url, {
|
|
916
|
+
method: "POST",
|
|
917
|
+
headers: { "Content-Type": "application/json" },
|
|
918
|
+
body: JSON.stringify({
|
|
919
|
+
givenContext: context,
|
|
920
|
+
metaContext: this.options.metaContext,
|
|
921
|
+
clientSdkKey: this.options.clientSdkKey,
|
|
922
|
+
instanceId: this.options.instanceId,
|
|
923
|
+
lastUpdateTimestamp: this.lastUpdateTimestamp
|
|
924
|
+
})
|
|
925
|
+
}, this.logger);
|
|
926
|
+
await this.handleNonOkResponse(response);
|
|
927
|
+
if (response.status == 200) {
|
|
928
|
+
const json = JSON.parse(await response.text());
|
|
929
|
+
this.lastUpdateTimestamp = json.timestamp;
|
|
930
|
+
this.eventEmitter.emit("configSetReceived", json);
|
|
931
|
+
}
|
|
932
|
+
} catch (fetchError) {
|
|
933
|
+
try {
|
|
934
|
+
this.handleFetchError(fetchError);
|
|
935
|
+
} catch (handledError) {
|
|
936
|
+
if (this.fatalError) this.eventEmitter.emit("connectionError", handledError);
|
|
937
|
+
throw handledError;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
on(name, handler) {
|
|
942
|
+
this.eventEmitter.on(name, handler);
|
|
943
|
+
}
|
|
944
|
+
off(name, handler) {
|
|
945
|
+
this.eventEmitter.off(name, handler);
|
|
946
|
+
}
|
|
947
|
+
clear() {
|
|
948
|
+
this.eventEmitter.clear();
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
//#endregion
|
|
952
|
+
//#region ../js-client-core/src/browser-globals.ts
|
|
953
|
+
const read = (readValue) => {
|
|
954
|
+
try {
|
|
955
|
+
return readValue(globalThis);
|
|
956
|
+
} catch {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
const readUserAgent = () => read((globals) => globals.navigator?.userAgent);
|
|
961
|
+
const readHost = () => read((globals) => globals.location?.host);
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region ../js-client-core/src/DefaultConfigDirectorClient.ts
|
|
964
|
+
const MAX_EXPONENTIAL_DELAY = 9;
|
|
965
|
+
var DefaultConfigDirectorClient = class {
|
|
966
|
+
logger;
|
|
967
|
+
telemetryClient;
|
|
968
|
+
configSet;
|
|
969
|
+
handlersMap = /* @__PURE__ */ new Map();
|
|
970
|
+
transport;
|
|
971
|
+
eventEmitter = new Emitter();
|
|
972
|
+
timeout;
|
|
973
|
+
timeoutTimer;
|
|
974
|
+
ready = false;
|
|
975
|
+
initializing = false;
|
|
976
|
+
hasReceivedConfigSet = false;
|
|
977
|
+
readyPromise;
|
|
978
|
+
readyResolve;
|
|
979
|
+
currentContext;
|
|
980
|
+
connectionMode;
|
|
981
|
+
instanceId;
|
|
982
|
+
constructor(telemetryClient, clientSdkKey, sdkOptions, clientOptions, internalClientOptions) {
|
|
983
|
+
this.logger = clientOptions?.logger ?? createDefaultLogger();
|
|
984
|
+
this.instanceId = resolveInstanceId(this.logger);
|
|
985
|
+
this.timeout = clientOptions?.connection?.timeout ?? 3e3;
|
|
986
|
+
this.validateSdkKeyPresence(clientSdkKey);
|
|
987
|
+
const urlFactory = internalClientOptions?.urlFactory ?? defaultUrlFactory;
|
|
988
|
+
const baseUrl = this.parseUrl(clientOptions?.connection?.url, urlFactory) ?? "https://client-sdk-api.configdirector.com";
|
|
989
|
+
this.connectionMode = clientOptions?.connection?.mode ?? "streaming";
|
|
990
|
+
this.telemetryClient = telemetryClient;
|
|
991
|
+
this.transport = internalClientOptions?.transport ?? this.buildTransport(clientSdkKey, sdkOptions, baseUrl, urlFactory, clientOptions, internalClientOptions);
|
|
992
|
+
this.transport.on("configSetReceived", (configSet) => {
|
|
993
|
+
this.hasReceivedConfigSet = true;
|
|
994
|
+
this.readyResolve?.();
|
|
995
|
+
const removedKeys = this.findKeysRemovedBy(configSet);
|
|
996
|
+
if (!this.configSet || configSet.kind == "full") this.configSet = configSet;
|
|
997
|
+
else this.configSet.configs = {
|
|
998
|
+
...this.configSet.configs,
|
|
999
|
+
...configSet.configs
|
|
1000
|
+
};
|
|
1001
|
+
const keys = Object.keys(configSet.configs);
|
|
1002
|
+
this.emit("configsUpdated", {
|
|
1003
|
+
keys,
|
|
1004
|
+
removedKeys
|
|
1005
|
+
});
|
|
1006
|
+
this.updateWatchers([...keys, ...removedKeys]);
|
|
1007
|
+
this.logger.debug("[ConfigDirectorClient] ConfigSet updated from server:", {
|
|
1008
|
+
keys,
|
|
1009
|
+
removedKeys
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
this.transport.on("connectionError", (error) => {
|
|
1013
|
+
this.initializing = false;
|
|
1014
|
+
this.logger.error("[ConfigDirectorClient] The connection encountered an unrecoverable error and will not be retried. Configs will no longer receive updates.", error);
|
|
1015
|
+
this.emit("connectionError", { error });
|
|
1016
|
+
});
|
|
1017
|
+
this.registerHandler("clientReady", clientOptions?.hooks?.clientReady);
|
|
1018
|
+
this.registerHandler("configEvaluated", clientOptions?.hooks?.configEvaluated);
|
|
1019
|
+
this.registerHandler("configsUpdated", clientOptions?.hooks?.configsUpdated);
|
|
1020
|
+
this.registerHandler("contextUpdated", clientOptions?.hooks?.contextUpdated);
|
|
1021
|
+
this.registerHandler("connectionError", clientOptions?.hooks?.connectionError);
|
|
1022
|
+
}
|
|
1023
|
+
registerHandler(event, handler) {
|
|
1024
|
+
if (!handler) return;
|
|
1025
|
+
const handlers = Array.isArray(handler) ? handler : [handler];
|
|
1026
|
+
for (const h of handlers) if (typeof h === "function") this.on(event, h);
|
|
1027
|
+
}
|
|
1028
|
+
resolvePollingIntervalForMode(configuredSeconds) {
|
|
1029
|
+
if (this.connectionMode !== "polling") return;
|
|
1030
|
+
const resolved = resolvePollingInterval(configuredSeconds, {
|
|
1031
|
+
defaultSeconds: 60,
|
|
1032
|
+
minimumSeconds: 30
|
|
1033
|
+
});
|
|
1034
|
+
if (resolved.warning) this.logger.warn(`[ConfigDirectorClient] ${resolved.warning}`);
|
|
1035
|
+
return resolved.seconds;
|
|
1036
|
+
}
|
|
1037
|
+
buildTransport(clientSdkKey, sdkOptions, baseUrl, urlFactory, clientOptions, internalClientOptions) {
|
|
1038
|
+
return new (this.getTransportConstructor(this.connectionMode))({
|
|
1039
|
+
instanceId: this.instanceId,
|
|
1040
|
+
clientSdkKey,
|
|
1041
|
+
baseUrl,
|
|
1042
|
+
resolveUrl: urlFactory,
|
|
1043
|
+
metaContext: {
|
|
1044
|
+
...clientOptions?.metadata,
|
|
1045
|
+
sdkName: sdkOptions.sdkName,
|
|
1046
|
+
sdkVersion: sdkOptions.sdkVersion,
|
|
1047
|
+
userAgent: readUserAgent(),
|
|
1048
|
+
host: readHost()
|
|
1049
|
+
},
|
|
1050
|
+
logger: this.logger,
|
|
1051
|
+
fetch: internalClientOptions?.fetch,
|
|
1052
|
+
connectionRetryDelay: internalClientOptions?.connectionRetryDelay ?? ((attempt) => {
|
|
1053
|
+
return Math.pow(2, Math.min(attempt, MAX_EXPONENTIAL_DELAY)) * 1e3;
|
|
1054
|
+
}),
|
|
1055
|
+
pollingInterval: this.resolvePollingIntervalForMode(clientOptions?.connection?.pollingInterval)
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
getTransportConstructor(connectionMode) {
|
|
1059
|
+
switch (connectionMode) {
|
|
1060
|
+
case "polling": return PollingTransport;
|
|
1061
|
+
default: return StreamingTransport;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
async initialize(context) {
|
|
1065
|
+
if (!this.hasReceivedConfigSet) this.initializing = true;
|
|
1066
|
+
await this.connectToTransport(context, "initialization");
|
|
1067
|
+
}
|
|
1068
|
+
async updateContext(context) {
|
|
1069
|
+
await this.connectToTransport(context, "context update");
|
|
1070
|
+
}
|
|
1071
|
+
get context() {
|
|
1072
|
+
return this.currentContext;
|
|
1073
|
+
}
|
|
1074
|
+
async connectToTransport(context, caller) {
|
|
1075
|
+
try {
|
|
1076
|
+
this.ready = false;
|
|
1077
|
+
this.readyPromise = new Promise((resolve) => {
|
|
1078
|
+
this.readyResolve = resolve;
|
|
1079
|
+
}).then((cancelled) => {
|
|
1080
|
+
if (cancelled) return;
|
|
1081
|
+
this.ready = true;
|
|
1082
|
+
this.initializing = false;
|
|
1083
|
+
this.emit("clientReady", { action: caller });
|
|
1084
|
+
this.logger.debug("[ConfigDirectorClient] Received initial payload from the server, client is ready");
|
|
1085
|
+
});
|
|
1086
|
+
const startTime = (/* @__PURE__ */ new Date()).getTime();
|
|
1087
|
+
await this.transport.connect(context ?? {}, this.timeout, caller);
|
|
1088
|
+
this.currentContext = context;
|
|
1089
|
+
this.telemetryClient.updateContext(context);
|
|
1090
|
+
this.emit("contextUpdated", { context });
|
|
1091
|
+
const elapsedTime = (/* @__PURE__ */ new Date()).getTime() - startTime;
|
|
1092
|
+
const remainingTimeout = this.timeout - elapsedTime;
|
|
1093
|
+
clearTimeout(this.timeoutTimer);
|
|
1094
|
+
this.timeoutTimer = void 0;
|
|
1095
|
+
if (remainingTimeout > 0) await Promise.race([this.readyPromise, new Promise((resolve) => {
|
|
1096
|
+
this.timeoutTimer = setTimeout(() => resolve(), remainingTimeout);
|
|
1097
|
+
})]).finally(() => clearTimeout(this.timeoutTimer));
|
|
1098
|
+
if (!this.ready) this.logger.warn(`[ConfigDirectorClient] Timed out waiting for ${caller} after ${this.timeout}ms. The client will continue to retry since there were no fatal errors detected. Configs will return the default value until the connection succeeds.`);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
this.logger.error(`[ConfigDirectorClient] An error occurred during ${caller}: `, error);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
findKeysRemovedBy(configSet) {
|
|
1104
|
+
if (!this.configSet || configSet.kind != "full") return [];
|
|
1105
|
+
return Object.keys(this.configSet.configs).filter((key) => !Object.prototype.hasOwnProperty.call(configSet.configs, key));
|
|
1106
|
+
}
|
|
1107
|
+
updateWatchers(configKeys) {
|
|
1108
|
+
configKeys.forEach((configKey) => this.updateWatchersForConfig(configKey));
|
|
1109
|
+
}
|
|
1110
|
+
updateWatchersForConfig(configKey) {
|
|
1111
|
+
const configState = this.configSet?.configs[configKey];
|
|
1112
|
+
this.handlersMap.get(configKey)?.forEach((h) => {
|
|
1113
|
+
const value = this.getValueFromConfigState(configKey, configState, h.defaultValue);
|
|
1114
|
+
h.handler(value);
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
watch(configKey, defaultValue, callback) {
|
|
1118
|
+
this.validateDefaultValue(defaultValue);
|
|
1119
|
+
const handlers = this.handlersMap.get(configKey);
|
|
1120
|
+
const handlerWithOptions = {
|
|
1121
|
+
handler: callback,
|
|
1122
|
+
defaultValue,
|
|
1123
|
+
requestedType: typeof defaultValue
|
|
1124
|
+
};
|
|
1125
|
+
if (handlers) handlers.push(handlerWithOptions);
|
|
1126
|
+
else this.handlersMap.set(configKey, [handlerWithOptions]);
|
|
1127
|
+
return () => this.unwatch(configKey, callback);
|
|
1128
|
+
}
|
|
1129
|
+
unwatch(configKey, callback) {
|
|
1130
|
+
const handlers = this.handlersMap.get(configKey);
|
|
1131
|
+
if (!handlers) return;
|
|
1132
|
+
if (callback) {
|
|
1133
|
+
const index = handlers.findIndex((h) => h.handler == callback);
|
|
1134
|
+
if (index >= 0) handlers?.splice(index, 1);
|
|
1135
|
+
} else handlers.splice(0);
|
|
1136
|
+
}
|
|
1137
|
+
getValue(configKey, defaultValue) {
|
|
1138
|
+
this.validateDefaultValue(defaultValue);
|
|
1139
|
+
const configState = this.configSet?.configs[configKey];
|
|
1140
|
+
return this.getValueFromConfigState(configKey, configState, defaultValue);
|
|
1141
|
+
}
|
|
1142
|
+
getValueFromConfigState(configKey, configState, defaultValue) {
|
|
1143
|
+
if (!configState) {
|
|
1144
|
+
this.logger.debug(`[ConfigDirectorClient] No config state found for '${configKey}', returning default value '${defaultValue}'`);
|
|
1145
|
+
const reason = this.isReady ? "config-state-missing" : "client-not-ready";
|
|
1146
|
+
this.telemetryClient.evaluatedConfig({
|
|
1147
|
+
contextId: this.currentContext?.id,
|
|
1148
|
+
key: configKey,
|
|
1149
|
+
defaultValue,
|
|
1150
|
+
requestedType: getRequestedType(defaultValue),
|
|
1151
|
+
evaluatedValue: defaultValue,
|
|
1152
|
+
usedDefault: true,
|
|
1153
|
+
evaluationReason: reason
|
|
1154
|
+
});
|
|
1155
|
+
this.dispatchEvaluationEvent({
|
|
1156
|
+
key: configKey,
|
|
1157
|
+
value: defaultValue,
|
|
1158
|
+
isDefaultValue: true,
|
|
1159
|
+
reason,
|
|
1160
|
+
context: this.context
|
|
1161
|
+
});
|
|
1162
|
+
return defaultValue;
|
|
1163
|
+
}
|
|
1164
|
+
const parseResult = parseConfigValue(configState, defaultValue);
|
|
1165
|
+
this.telemetryClient.evaluatedConfig({
|
|
1166
|
+
contextId: this.currentContext?.id,
|
|
1167
|
+
type: configState.type,
|
|
1168
|
+
key: configKey,
|
|
1169
|
+
defaultValue,
|
|
1170
|
+
requestedType: parseResult.requestedType,
|
|
1171
|
+
evaluatedValue: parseResult.parsedValue,
|
|
1172
|
+
evaluatedValueId: parseResult.parsedValueId,
|
|
1173
|
+
usedDefault: parseResult.usedDefault,
|
|
1174
|
+
evaluationReason: parseResult.reason
|
|
1175
|
+
});
|
|
1176
|
+
this.dispatchEvaluationEvent({
|
|
1177
|
+
key: configKey,
|
|
1178
|
+
value: parseResult.parsedValue,
|
|
1179
|
+
valueId: parseResult.parsedValueId ?? void 0,
|
|
1180
|
+
isDefaultValue: parseResult.usedDefault,
|
|
1181
|
+
reason: parseResult.reason,
|
|
1182
|
+
context: this.context
|
|
1183
|
+
});
|
|
1184
|
+
this.logger.debug(`[ConfigDirectorClient] Evaluated '${configKey}' to '${parseResult.parsedValue}'`);
|
|
1185
|
+
return parseResult.parsedValue;
|
|
1186
|
+
}
|
|
1187
|
+
dispatchEvaluationEvent(event) {
|
|
1188
|
+
setTimeout(() => this.emit("configEvaluated", { evaluation: event }), 0);
|
|
1189
|
+
}
|
|
1190
|
+
emit(name, payload) {
|
|
1191
|
+
try {
|
|
1192
|
+
this.eventEmitter.emit(name, payload);
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
this.logger.error(`[ConfigDirectorClient] Error executing event handlers for '${name}'`, error);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
parseUrl(url, urlFactory) {
|
|
1198
|
+
if (!url) return;
|
|
1199
|
+
try {
|
|
1200
|
+
return urlFactory(url);
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
throw new ConfigDirectorValidationError(`Invalid base URL '${url}'. Parsing failed: ${error}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
validateSdkKeyPresence(sdkKey) {
|
|
1206
|
+
if (!sdkKey || sdkKey.trim().length == 0) throw new ConfigDirectorValidationError("No client SDK key was provided, the client cannot be instantiated without a valid client SDK key");
|
|
1207
|
+
}
|
|
1208
|
+
validateDefaultValue(defaultValue) {
|
|
1209
|
+
if (defaultValue === void 0 || defaultValue === null) throw new ConfigDirectorValidationError("Invalid default value. The default value for a config must be defined and non-null.");
|
|
1210
|
+
if (typeof defaultValue === "function") throw new ConfigDirectorValidationError("Invalid default value. The default value for a config cannot be a function.");
|
|
1211
|
+
}
|
|
1212
|
+
get isReady() {
|
|
1213
|
+
return this.ready;
|
|
1214
|
+
}
|
|
1215
|
+
get isInitializing() {
|
|
1216
|
+
return this.initializing;
|
|
1217
|
+
}
|
|
1218
|
+
on(eventName, handler) {
|
|
1219
|
+
this.eventEmitter.on(eventName, handler);
|
|
1220
|
+
}
|
|
1221
|
+
off(eventName, handler) {
|
|
1222
|
+
this.eventEmitter.off(eventName, handler);
|
|
1223
|
+
}
|
|
1224
|
+
clear() {
|
|
1225
|
+
this.logger.debug("[ConfigDirectorClient] clear() has been called, removing all observers");
|
|
1226
|
+
this.eventEmitter.clear();
|
|
1227
|
+
this.handlersMap.clear();
|
|
1228
|
+
}
|
|
1229
|
+
unwatchAll() {
|
|
1230
|
+
this.handlersMap.clear();
|
|
1231
|
+
}
|
|
1232
|
+
pauseNetwork() {
|
|
1233
|
+
this.logger.debug("[ConfigDirectorClient] pauseNetwork() called, pausing transport connection");
|
|
1234
|
+
clearTimeout(this.timeoutTimer);
|
|
1235
|
+
this.timeoutTimer = void 0;
|
|
1236
|
+
this.transport.close();
|
|
1237
|
+
this.ready = false;
|
|
1238
|
+
}
|
|
1239
|
+
async resumeNetwork() {
|
|
1240
|
+
await this.connectToTransport(this.currentContext, "network resume");
|
|
1241
|
+
}
|
|
1242
|
+
async close() {
|
|
1243
|
+
this.logger.debug("[ConfigDirectorClient] close() has been called, closing connection to server");
|
|
1244
|
+
clearTimeout(this.timeoutTimer);
|
|
1245
|
+
this.readyResolve?.(true);
|
|
1246
|
+
this.telemetryClient.close();
|
|
1247
|
+
this.transport.close();
|
|
1248
|
+
this.ready = false;
|
|
1249
|
+
this.initializing = false;
|
|
1250
|
+
}
|
|
1251
|
+
dispose() {
|
|
1252
|
+
this.clear();
|
|
1253
|
+
this.close();
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
//#endregion
|
|
1257
|
+
//#region ../shared/src/testing/values.ts
|
|
1258
|
+
const encodeTestValue$1 = (key, value) => {
|
|
1259
|
+
if (key.trim().length == 0) throw new ConfigDirectorValidationError("Invalid config key. The key of a test value cannot be empty.");
|
|
1260
|
+
return encode(key, value);
|
|
1261
|
+
};
|
|
1262
|
+
const encode = (key, value) => {
|
|
1263
|
+
switch (typeof value) {
|
|
1264
|
+
case "boolean": return {
|
|
1265
|
+
type: "boolean",
|
|
1266
|
+
value: value ? "true" : "false"
|
|
1267
|
+
};
|
|
1268
|
+
case "number": return encodeNumber(key, value);
|
|
1269
|
+
case "string": return {
|
|
1270
|
+
type: "string",
|
|
1271
|
+
value
|
|
1272
|
+
};
|
|
1273
|
+
case "object": if (value !== null) {
|
|
1274
|
+
assertJsonContents(key, value, "");
|
|
1275
|
+
return {
|
|
1276
|
+
type: "json",
|
|
1277
|
+
value: JSON.stringify(value)
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
throw new ConfigDirectorValidationError(`Invalid test value for '${key}': ${describe(value)}. Use a boolean, a number, a string, a plain object, or an array.`);
|
|
1282
|
+
};
|
|
1283
|
+
const encodeNumber = (key, value) => {
|
|
1284
|
+
if (!Number.isFinite(value)) throw new ConfigDirectorValidationError(`Invalid test value for '${key}': ${describe(value)} is not a finite number.`);
|
|
1285
|
+
if (Number.isInteger(value)) {
|
|
1286
|
+
if (!Number.isSafeInteger(value)) throw new ConfigDirectorValidationError(`Invalid test value for '${key}': ${value} is not a safe integer, so the SDK could not read it back exactly.`);
|
|
1287
|
+
return {
|
|
1288
|
+
type: "integer",
|
|
1289
|
+
value: String(value)
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
return {
|
|
1293
|
+
type: "float",
|
|
1294
|
+
value: toPlainDecimal(value)
|
|
1295
|
+
};
|
|
1296
|
+
};
|
|
1297
|
+
const toPlainDecimal = (value) => {
|
|
1298
|
+
const text = String(value);
|
|
1299
|
+
const exponentIndex = text.indexOf("e-");
|
|
1300
|
+
if (exponentIndex < 0) return text;
|
|
1301
|
+
const sign = text.startsWith("-") ? "-" : "";
|
|
1302
|
+
const [integerDigits = "", fractionDigits = ""] = text.slice(sign.length, exponentIndex).split(".");
|
|
1303
|
+
const leadingZeros = Number(text.slice(exponentIndex + 2)) - integerDigits.length;
|
|
1304
|
+
return `${sign}0.${"0".repeat(leadingZeros)}${integerDigits}${fractionDigits}`;
|
|
1305
|
+
};
|
|
1306
|
+
const assertJsonContents = (key, value, path) => {
|
|
1307
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return;
|
|
1308
|
+
if (typeof value === "number") {
|
|
1309
|
+
if (!Number.isFinite(value)) throw invalidJsonContents(key, path, `${describe(value)} is not a finite number`);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (Array.isArray(value)) {
|
|
1313
|
+
value.forEach((item, index) => assertJsonContents(key, item, `${path}[${index}]`));
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (isPlainObject(value)) {
|
|
1317
|
+
for (const [property, item] of Object.entries(value)) assertJsonContents(key, item, path ? `${path}.${property}` : property);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
throw invalidJsonContents(key, path, `${describe(value)} cannot be encoded as JSON`);
|
|
1321
|
+
};
|
|
1322
|
+
const invalidJsonContents = (key, path, problem) => new ConfigDirectorValidationError(`Invalid test value for '${key}'${path ? ` at '${path}'` : ""}: ${problem}. JSON contents can hold booleans, finite numbers, strings, null, arrays, and plain objects.`);
|
|
1323
|
+
const isPlainObject = (value) => {
|
|
1324
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1325
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1326
|
+
return prototype === Object.prototype || prototype === null;
|
|
1327
|
+
};
|
|
1328
|
+
const describe = (value) => {
|
|
1329
|
+
if (value === null) return "null";
|
|
1330
|
+
if (typeof value === "function") return "a function";
|
|
1331
|
+
if (typeof value === "object") return `a ${value.constructor?.name ?? "object"} instance`;
|
|
1332
|
+
if (typeof value === "symbol") return "a symbol";
|
|
1333
|
+
return String(value);
|
|
1334
|
+
};
|
|
1335
|
+
//#endregion
|
|
1336
|
+
//#region ../js-client-core/src/testing/values.ts
|
|
1337
|
+
const encodeTestValue = (key, value, revision) => {
|
|
1338
|
+
const encoded = encodeTestValue$1(key, value);
|
|
1339
|
+
return {
|
|
1340
|
+
id: `test-config:${key}`,
|
|
1341
|
+
key,
|
|
1342
|
+
type: encoded.type,
|
|
1343
|
+
value: encoded.value,
|
|
1344
|
+
valueId: `test-value:${key}:${revision}`
|
|
1345
|
+
};
|
|
1346
|
+
};
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region ../js-client-core/src/testing/InMemoryConnection.ts
|
|
1349
|
+
const TEST_ENVIRONMENT = {
|
|
1350
|
+
environmentId: "test-environment",
|
|
1351
|
+
projectId: "test-project"
|
|
1352
|
+
};
|
|
1353
|
+
const controlledAttemptFor = (reason) => {
|
|
1354
|
+
switch (reason) {
|
|
1355
|
+
case "initialization": return "initialize";
|
|
1356
|
+
case "context update": return "updateContext";
|
|
1357
|
+
default: return;
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
var InMemoryConnection = class {
|
|
1361
|
+
emitter = new Emitter();
|
|
1362
|
+
configs = /* @__PURE__ */ new Map();
|
|
1363
|
+
recordedContexts = [];
|
|
1364
|
+
pendingDeliveries = [];
|
|
1365
|
+
armed = {
|
|
1366
|
+
initialize: "nothing",
|
|
1367
|
+
updateContext: "nothing"
|
|
1368
|
+
};
|
|
1369
|
+
pendingHold;
|
|
1370
|
+
connected = false;
|
|
1371
|
+
delivering = false;
|
|
1372
|
+
revision = 0;
|
|
1373
|
+
attemptGeneration = 0;
|
|
1374
|
+
constructor(logger, values) {
|
|
1375
|
+
this.logger = logger;
|
|
1376
|
+
this.store(values);
|
|
1377
|
+
}
|
|
1378
|
+
get contextUpdates() {
|
|
1379
|
+
return [...this.recordedContexts];
|
|
1380
|
+
}
|
|
1381
|
+
setValue(key, value) {
|
|
1382
|
+
const configState = encodeTestValue(key, value, ++this.revision);
|
|
1383
|
+
this.configs.set(key, configState);
|
|
1384
|
+
this.deliverIfConnected({
|
|
1385
|
+
...TEST_ENVIRONMENT,
|
|
1386
|
+
kind: "delta",
|
|
1387
|
+
configs: { [key]: configState }
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
removeValue(key) {
|
|
1391
|
+
this.configs.delete(key);
|
|
1392
|
+
this.deliverIfConnected(this.fullUpdate());
|
|
1393
|
+
}
|
|
1394
|
+
replaceValues(values) {
|
|
1395
|
+
const encoded = this.encodeAll(values);
|
|
1396
|
+
this.configs.clear();
|
|
1397
|
+
encoded.forEach((configState, key) => this.configs.set(key, configState));
|
|
1398
|
+
this.armed.initialize = "nothing";
|
|
1399
|
+
this.armed.updateContext = "nothing";
|
|
1400
|
+
this.deliverIfConnected(this.fullUpdate());
|
|
1401
|
+
}
|
|
1402
|
+
hold(attempt) {
|
|
1403
|
+
this.armed[attempt] = "hold";
|
|
1404
|
+
}
|
|
1405
|
+
complete(attempt) {
|
|
1406
|
+
if (this.pendingHold?.attempt === attempt) this.pendingHold.complete();
|
|
1407
|
+
else if (this.armed[attempt] === "hold") this.armed[attempt] = "nothing";
|
|
1408
|
+
}
|
|
1409
|
+
fail(attempt) {
|
|
1410
|
+
if (this.pendingHold?.attempt === attempt) this.pendingHold.fail();
|
|
1411
|
+
else this.armed[attempt] = "failure";
|
|
1412
|
+
}
|
|
1413
|
+
connect(context, timeout, reason) {
|
|
1414
|
+
this.endAttempt();
|
|
1415
|
+
const attempt = controlledAttemptFor(reason);
|
|
1416
|
+
if (!attempt) return this.connectNow();
|
|
1417
|
+
this.recordedContexts.push(context);
|
|
1418
|
+
const armed = this.armed[attempt];
|
|
1419
|
+
this.armed[attempt] = "nothing";
|
|
1420
|
+
switch (armed) {
|
|
1421
|
+
case "failure": return this.failNow(reason);
|
|
1422
|
+
case "hold": return this.holdNow(attempt, reason, timeout);
|
|
1423
|
+
default: return this.connectNow();
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
on = (name, handler) => this.emitter.on(name, handler);
|
|
1427
|
+
off = (name, handler) => this.emitter.off(name, handler);
|
|
1428
|
+
clear() {
|
|
1429
|
+
this.emitter.clear();
|
|
1430
|
+
}
|
|
1431
|
+
close() {
|
|
1432
|
+
this.endAttempt();
|
|
1433
|
+
}
|
|
1434
|
+
dispose() {
|
|
1435
|
+
this.close();
|
|
1436
|
+
this.clear();
|
|
1437
|
+
}
|
|
1438
|
+
endAttempt() {
|
|
1439
|
+
this.attemptGeneration++;
|
|
1440
|
+
this.connected = false;
|
|
1441
|
+
this.pendingHold?.end();
|
|
1442
|
+
}
|
|
1443
|
+
connectNow() {
|
|
1444
|
+
const connected = Promise.resolve(this);
|
|
1445
|
+
this.scheduleFirstDelivery();
|
|
1446
|
+
return connected;
|
|
1447
|
+
}
|
|
1448
|
+
holdNow(attempt, reason, timeout) {
|
|
1449
|
+
return new Promise((resolve, reject) => {
|
|
1450
|
+
const timer = setTimeout(() => this.pendingHold?.end(), timeout);
|
|
1451
|
+
const release = () => {
|
|
1452
|
+
clearTimeout(timer);
|
|
1453
|
+
this.pendingHold = void 0;
|
|
1454
|
+
};
|
|
1455
|
+
this.pendingHold = {
|
|
1456
|
+
attempt,
|
|
1457
|
+
end: () => {
|
|
1458
|
+
release();
|
|
1459
|
+
resolve(this);
|
|
1460
|
+
},
|
|
1461
|
+
complete: () => {
|
|
1462
|
+
release();
|
|
1463
|
+
resolve(this);
|
|
1464
|
+
this.scheduleFirstDelivery();
|
|
1465
|
+
},
|
|
1466
|
+
fail: () => {
|
|
1467
|
+
release();
|
|
1468
|
+
const error = this.fatalError(reason);
|
|
1469
|
+
this.emitter.emit("connectionError", error);
|
|
1470
|
+
reject(error);
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
failNow(reason) {
|
|
1476
|
+
const error = this.fatalError(reason);
|
|
1477
|
+
this.emitter.emit("connectionError", error);
|
|
1478
|
+
return Promise.reject(error);
|
|
1479
|
+
}
|
|
1480
|
+
fatalError(reason) {
|
|
1481
|
+
return new ConfigDirectorConnectionError(`Connection failed with status: 401. Error: the test client failed this ${reason}. This is an unrecoverable error, will not attempt to reconnect.`, 401);
|
|
1482
|
+
}
|
|
1483
|
+
scheduleFirstDelivery() {
|
|
1484
|
+
const generation = this.attemptGeneration;
|
|
1485
|
+
Promise.resolve().then(() => {}).then(() => {
|
|
1486
|
+
if (generation !== this.attemptGeneration) return;
|
|
1487
|
+
this.connected = true;
|
|
1488
|
+
this.deliver(this.fullUpdate());
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
deliverIfConnected(configSet) {
|
|
1492
|
+
if (this.connected) this.deliver(configSet);
|
|
1493
|
+
}
|
|
1494
|
+
deliver(configSet) {
|
|
1495
|
+
this.pendingDeliveries.push(configSet);
|
|
1496
|
+
if (this.delivering) return;
|
|
1497
|
+
this.delivering = true;
|
|
1498
|
+
try {
|
|
1499
|
+
let next = this.pendingDeliveries.shift();
|
|
1500
|
+
while (next) {
|
|
1501
|
+
this.emitCaught(next);
|
|
1502
|
+
next = this.pendingDeliveries.shift();
|
|
1503
|
+
}
|
|
1504
|
+
} finally {
|
|
1505
|
+
this.delivering = false;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
emitCaught(configSet) {
|
|
1509
|
+
try {
|
|
1510
|
+
this.emitter.emit("configSetReceived", configSet);
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
this.logger.error("[InMemoryConnection] Error dispatching a config update from the test client: ", error);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
fullUpdate() {
|
|
1516
|
+
return {
|
|
1517
|
+
...TEST_ENVIRONMENT,
|
|
1518
|
+
kind: "full",
|
|
1519
|
+
configs: Object.fromEntries(this.configs)
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
store(values) {
|
|
1523
|
+
this.encodeAll(values).forEach((configState, key) => this.configs.set(key, configState));
|
|
1524
|
+
}
|
|
1525
|
+
encodeAll(values) {
|
|
1526
|
+
const encoded = /* @__PURE__ */ new Map();
|
|
1527
|
+
for (const [key, value] of Object.entries(values)) encoded.set(key, encodeTestValue(key, value, ++this.revision));
|
|
1528
|
+
return encoded;
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
//#endregion
|
|
1532
|
+
//#region ../js-client-core/src/testing/TestClient.ts
|
|
1533
|
+
const TEST_CLIENT_SDK_KEY = "test-client";
|
|
1534
|
+
const noopTelemetryClient = {
|
|
1535
|
+
updateContext: async () => {},
|
|
1536
|
+
evaluatedConfig: () => {},
|
|
1537
|
+
close: async () => {}
|
|
1538
|
+
};
|
|
1539
|
+
var InMemoryTestClient = class {
|
|
1540
|
+
constructor(client, connection) {
|
|
1541
|
+
this.client = client;
|
|
1542
|
+
this.connection = connection;
|
|
1543
|
+
}
|
|
1544
|
+
setValue(key, value) {
|
|
1545
|
+
this.connection.setValue(key, value);
|
|
1546
|
+
}
|
|
1547
|
+
removeValue(key) {
|
|
1548
|
+
this.connection.removeValue(key);
|
|
1549
|
+
}
|
|
1550
|
+
replaceValues(values) {
|
|
1551
|
+
this.connection.replaceValues(values);
|
|
1552
|
+
}
|
|
1553
|
+
holdInitialization() {
|
|
1554
|
+
this.connection.hold("initialize");
|
|
1555
|
+
}
|
|
1556
|
+
completeInitialization() {
|
|
1557
|
+
this.connection.complete("initialize");
|
|
1558
|
+
}
|
|
1559
|
+
failInitialization() {
|
|
1560
|
+
this.connection.fail("initialize");
|
|
1561
|
+
}
|
|
1562
|
+
holdContextUpdate() {
|
|
1563
|
+
this.connection.hold("updateContext");
|
|
1564
|
+
}
|
|
1565
|
+
completeContextUpdate() {
|
|
1566
|
+
this.connection.complete("updateContext");
|
|
1567
|
+
}
|
|
1568
|
+
failContextUpdate() {
|
|
1569
|
+
this.connection.fail("updateContext");
|
|
1570
|
+
}
|
|
1571
|
+
get contextUpdates() {
|
|
1572
|
+
return this.connection.contextUpdates;
|
|
1573
|
+
}
|
|
1574
|
+
};
|
|
1575
|
+
const createTestClient$1 = (sdkOptions, options) => {
|
|
1576
|
+
const logger = options?.logger ?? createDefaultLogger();
|
|
1577
|
+
const connection = new InMemoryConnection(logger, options?.values ?? {});
|
|
1578
|
+
return new InMemoryTestClient(new DefaultConfigDirectorClient(noopTelemetryClient, TEST_CLIENT_SDK_KEY, sdkOptions, {
|
|
1579
|
+
logger,
|
|
1580
|
+
connection: { timeout: options?.timeout }
|
|
1581
|
+
}, { transport: connection }), connection);
|
|
1582
|
+
};
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region ../js-client-core/src/installedTestClient.ts
|
|
1585
|
+
const INSTALLED_TEST_CLIENT_SLOT = Symbol.for("@configdirector/installed-test-client");
|
|
1586
|
+
//#endregion
|
|
1587
|
+
//#region ../js-client-core/src/testing/installTestClient.ts
|
|
1588
|
+
/**
|
|
1589
|
+
* Makes every `ConfigDirectorProvider` mounted without a `client` prop, and every client
|
|
1590
|
+
* `withProvider` would build, use `testClient.client` instead of building a client, until the
|
|
1591
|
+
* returned function uninstalls it. Construction props such as `sdkKey` are ignored while installed;
|
|
1592
|
+
* `context` and `hooks` apply to the installed client as they do for a `client` prop. A provider
|
|
1593
|
+
* reads the installed client when it is constructed, so install before rendering.
|
|
1594
|
+
*
|
|
1595
|
+
* Installing again replaces the installed test client. Each uninstall function restores the test
|
|
1596
|
+
* client that was installed when it was created, so nested installs are uninstalled in reverse order.
|
|
1597
|
+
*
|
|
1598
|
+
* @param testClient The test client whose `client` providers should use
|
|
1599
|
+
* @returns A function that uninstalls it
|
|
1600
|
+
*
|
|
1601
|
+
* @example
|
|
1602
|
+
* let uninstallTestClient: () => void;
|
|
1603
|
+
* beforeEach(() => {
|
|
1604
|
+
* testClient = createTestClient({ values: { "new-checkout": true } });
|
|
1605
|
+
* uninstallTestClient = installTestClient(testClient);
|
|
1606
|
+
* });
|
|
1607
|
+
* afterEach(() => uninstallTestClient());
|
|
1608
|
+
*/
|
|
1609
|
+
const installTestClient = (testClient) => {
|
|
1610
|
+
const slot = globalThis;
|
|
1611
|
+
const previous = slot[INSTALLED_TEST_CLIENT_SLOT];
|
|
1612
|
+
slot[INSTALLED_TEST_CLIENT_SLOT] = testClient.client;
|
|
1613
|
+
return () => {
|
|
1614
|
+
slot[INSTALLED_TEST_CLIENT_SLOT] = previous;
|
|
1615
|
+
};
|
|
1616
|
+
};
|
|
1617
|
+
//#endregion
|
|
1618
|
+
//#region src/testing.ts
|
|
1619
|
+
/**
|
|
1620
|
+
* Creates a {@link TestClient}: a real `ConfigDirectorClient` connected to an in-memory server
|
|
1621
|
+
* that the test controls. No network connection is opened and no telemetry is sent. Hand it to the
|
|
1622
|
+
* ConfigDirector Nuxt plugin with `installTestClient` before the Nuxt app is created.
|
|
1623
|
+
*
|
|
1624
|
+
* @param options {@link TestClientOptions}: the initial values, the client's timeout, and its logger
|
|
1625
|
+
* @returns A {@link TestClient} whose `client` the plugin provides to the app
|
|
1626
|
+
*
|
|
1627
|
+
* @example
|
|
1628
|
+
* import { createTestClient, installTestClient } from "@configdirector/nuxt-sdk/testing";
|
|
1629
|
+
*
|
|
1630
|
+
* const testClient = createTestClient({ values: { "new-checkout": true } });
|
|
1631
|
+
* installTestClient(testClient);
|
|
1632
|
+
*
|
|
1633
|
+
* test("shows the new checkout when the flag is on", async () => {
|
|
1634
|
+
* const component = await mountSuspended(Checkout);
|
|
1635
|
+
* expect(component.text()).toContain("New checkout");
|
|
1636
|
+
* });
|
|
1637
|
+
*/
|
|
1638
|
+
const createTestClient = (options) => {
|
|
1639
|
+
return createTestClient$1({
|
|
1640
|
+
sdkName: "nuxt-sdk",
|
|
1641
|
+
sdkVersion: "1.8.0"
|
|
1642
|
+
}, options);
|
|
1643
|
+
};
|
|
1644
|
+
//#endregion
|
|
1645
|
+
export { ConfigDirectorConnectionError, ConfigDirectorValidationError, createTestClient, installTestClient };
|