@shelbyb/daywalker-sdk 0.3.0 → 0.4.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 +31 -23
- package/dist/analytics/index.d.ts +16 -2
- package/dist/analytics/index.js +79 -14
- package/dist/cli.js +133 -31
- package/dist/client.d.ts +29 -2
- package/dist/client.js +314 -62
- package/dist/config.d.ts +8 -2
- package/dist/config.js +14 -0
- package/dist/crypto/encoding.d.ts +5 -0
- package/dist/crypto/encoding.js +14 -0
- package/dist/crypto/index.d.ts +3 -3
- package/dist/crypto/index.js +2 -2
- package/dist/crypto/keys.d.ts +3 -3
- package/dist/crypto/keys.js +28 -7
- package/dist/crypto/signatures.d.ts +42 -12
- package/dist/crypto/signatures.js +236 -79
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +32 -4
- package/dist/flags/client.js +74 -12
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +51 -6
- package/dist/flags/common/index.d.ts +3 -3
- package/dist/flags/common/index.js +3 -3
- package/dist/flags/common/sse.d.ts +24 -3
- package/dist/flags/common/sse.js +85 -15
- package/dist/flags/common/version.d.ts +2 -0
- package/dist/flags/common/version.js +2 -0
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +20 -3
- package/dist/flags/engine/operators.js +38 -32
- package/dist/flags/node.d.ts +26 -14
- package/dist/flags/node.js +51 -7
- package/dist/flags/react.d.ts +6 -4
- package/dist/flags/react.js +47 -17
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +28 -7
- package/dist/next.d.ts +52 -8
- package/dist/next.js +151 -37
- package/dist/openfeature/server.d.ts +3 -0
- package/dist/openfeature/server.js +14 -2
- package/dist/openfeature/shared.d.ts +1 -1
- package/dist/openfeature/shared.js +22 -2
- package/dist/openfeature/web.js +9 -3
- package/dist/types.d.ts +56 -4
- package/dist/types.js +6 -0
- package/docs/analytics.md +1 -1
- package/docs/flags-node.md +1 -1
- package/docs/flags.md +2 -1
- package/package.json +32 -14
- package/assets/logo-512.png +0 -0
package/dist/flags/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { EventProcessor, SseClient, encodeContext, noopLogger } from "./common/index.js";
|
|
1
|
+
import { EventProcessor, SDK_VERSION, SseClient, encodeContext, noopLogger } from "./common/index.js";
|
|
2
2
|
import { assertSecureUrl } from "../config.js";
|
|
3
|
+
export { SDK_VERSION };
|
|
3
4
|
/** A flag map without a prototype, so keys like `__proto__` or `toString` never resolve to inherited values. */
|
|
4
5
|
function flagMap(entries = {}) {
|
|
5
6
|
return Object.assign(Object.create(null), entries);
|
|
@@ -29,6 +30,10 @@ export class DevPulseClient {
|
|
|
29
30
|
rejectReady;
|
|
30
31
|
initialized = false;
|
|
31
32
|
closed = false;
|
|
33
|
+
/** Bumped by identify(); responses for an older context are ignored. */
|
|
34
|
+
contextSeq = 0;
|
|
35
|
+
stale = false;
|
|
36
|
+
detachVisibility = null;
|
|
32
37
|
logger;
|
|
33
38
|
base;
|
|
34
39
|
constructor(clientSideId, context, options) {
|
|
@@ -54,6 +59,8 @@ export class DevPulseClient {
|
|
|
54
59
|
if (cached)
|
|
55
60
|
this.flags = flagMap(cached);
|
|
56
61
|
}
|
|
62
|
+
if (options.offline)
|
|
63
|
+
return;
|
|
57
64
|
if (options.sendEvents !== false && typeof globalThis.fetch === "function") {
|
|
58
65
|
this.events = new EventProcessor({
|
|
59
66
|
url: `${this.base}/sdk/v1/events/client/${encodeURIComponent(clientSideId)}`,
|
|
@@ -66,10 +73,14 @@ export class DevPulseClient {
|
|
|
66
73
|
});
|
|
67
74
|
this.events.identify(context);
|
|
68
75
|
const doc = globalThis.document;
|
|
69
|
-
doc?.addEventListener
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
if (doc?.addEventListener) {
|
|
77
|
+
const onVisibility = () => {
|
|
78
|
+
if (doc.visibilityState === "hidden")
|
|
79
|
+
void this.events?.flush();
|
|
80
|
+
};
|
|
81
|
+
doc.addEventListener("visibilitychange", onVisibility);
|
|
82
|
+
this.detachVisibility = () => doc.removeEventListener("visibilitychange", onVisibility);
|
|
83
|
+
}
|
|
73
84
|
}
|
|
74
85
|
void this.start();
|
|
75
86
|
}
|
|
@@ -84,10 +95,11 @@ export class DevPulseClient {
|
|
|
84
95
|
async start() {
|
|
85
96
|
if (this.options.streaming === false) {
|
|
86
97
|
try {
|
|
87
|
-
|
|
98
|
+
await this.loadFlags();
|
|
88
99
|
}
|
|
89
100
|
catch (err) {
|
|
90
|
-
this.
|
|
101
|
+
if (!this.closed)
|
|
102
|
+
this.fail(err);
|
|
91
103
|
}
|
|
92
104
|
return;
|
|
93
105
|
}
|
|
@@ -95,7 +107,10 @@ export class DevPulseClient {
|
|
|
95
107
|
url: () => `${this.base}/sdk/v1/client/${encodeURIComponent(this.clientSideId)}/stream?${this.query()}`,
|
|
96
108
|
fetch: this.options.fetch,
|
|
97
109
|
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
98
|
-
onOpen: () =>
|
|
110
|
+
onOpen: () => {
|
|
111
|
+
this.logger.debug("DevPulse stream connected");
|
|
112
|
+
this.setStale(false);
|
|
113
|
+
},
|
|
99
114
|
onError: (err) => {
|
|
100
115
|
this.logger.warn(`DevPulse stream error: ${err.message}`);
|
|
101
116
|
if (!err.recoverable) {
|
|
@@ -103,13 +118,23 @@ export class DevPulseClient {
|
|
|
103
118
|
return false;
|
|
104
119
|
}
|
|
105
120
|
// Keep serving flags while reconnecting; fetch once so a first load still succeeds.
|
|
121
|
+
this.setStale(true, err);
|
|
106
122
|
if (!this.initialized)
|
|
107
|
-
this.
|
|
123
|
+
this.loadFlags().catch(() => undefined);
|
|
108
124
|
return true;
|
|
109
125
|
},
|
|
110
126
|
});
|
|
111
127
|
this.stream.start();
|
|
112
128
|
}
|
|
129
|
+
/** Fetch flags for the current context and apply them unless identify() moved on (or close()) meanwhile. */
|
|
130
|
+
async loadFlags() {
|
|
131
|
+
const seq = this.contextSeq;
|
|
132
|
+
const flags = await this.fetchFlags();
|
|
133
|
+
if (seq !== this.contextSeq || this.closed)
|
|
134
|
+
return false;
|
|
135
|
+
this.replaceAll(flags);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
113
138
|
async fetchFlags() {
|
|
114
139
|
const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/client/${encodeURIComponent(this.clientSideId)}/flags?${this.query()}`);
|
|
115
140
|
if (!res.ok)
|
|
@@ -168,6 +193,16 @@ export class DevPulseClient {
|
|
|
168
193
|
this.resolveReady();
|
|
169
194
|
this.emit("ready");
|
|
170
195
|
}
|
|
196
|
+
/** "stale" when the stream drops after loading (flags keep serving), "recovered" once it reconnects. */
|
|
197
|
+
setStale(stale, err) {
|
|
198
|
+
if (!this.initialized || this.closed || this.stale === stale)
|
|
199
|
+
return;
|
|
200
|
+
this.stale = stale;
|
|
201
|
+
if (stale)
|
|
202
|
+
this.emit("stale", err);
|
|
203
|
+
else
|
|
204
|
+
this.emit("recovered");
|
|
205
|
+
}
|
|
171
206
|
fail(err) {
|
|
172
207
|
if (!this.initialized)
|
|
173
208
|
this.rejectReady(err);
|
|
@@ -214,10 +249,20 @@ export class DevPulseClient {
|
|
|
214
249
|
get isInitialized() {
|
|
215
250
|
return this.initialized;
|
|
216
251
|
}
|
|
252
|
+
/** True after close(); a closed client never updates again. */
|
|
253
|
+
get isClosed() {
|
|
254
|
+
return this.closed;
|
|
255
|
+
}
|
|
217
256
|
variation(key, defaultValue) {
|
|
218
257
|
return this.variationDetail(key, defaultValue).value;
|
|
219
258
|
}
|
|
220
259
|
variationDetail(key, defaultValue) {
|
|
260
|
+
const detail = this.peekVariationDetail(key, defaultValue);
|
|
261
|
+
this.recordEvaluation(key, defaultValue, detail);
|
|
262
|
+
return detail;
|
|
263
|
+
}
|
|
264
|
+
/** @internal Evaluate without recording an analytics event (React hooks record from an effect). */
|
|
265
|
+
peekVariationDetail(key, defaultValue) {
|
|
221
266
|
const f = this.flag(key);
|
|
222
267
|
let detail;
|
|
223
268
|
if (!f)
|
|
@@ -229,9 +274,13 @@ export class DevPulseClient {
|
|
|
229
274
|
detail = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "WRONG_TYPE" } };
|
|
230
275
|
else
|
|
231
276
|
detail = { value: f.value, variationIndex: f.variation, reason: f.reason ?? null };
|
|
232
|
-
this.events?.recordEvaluation({ key, context: this.context, value: detail.value, default: defaultValue, variation: detail.variationIndex, version: f?.version, reason: f?.reason, trackEvents: f?.trackEvents });
|
|
233
277
|
return detail;
|
|
234
278
|
}
|
|
279
|
+
/** @internal Record the analytics event for an evaluation made with peekVariationDetail(). */
|
|
280
|
+
recordEvaluation(key, defaultValue, detail) {
|
|
281
|
+
const f = this.flag(key);
|
|
282
|
+
this.events?.recordEvaluation({ key, context: this.context, value: detail.value, default: defaultValue, variation: detail.variationIndex, version: f?.version, reason: f?.reason, trackEvents: f?.trackEvents });
|
|
283
|
+
}
|
|
235
284
|
boolVariation(key, defaultValue) {
|
|
236
285
|
return this.variation(key, defaultValue);
|
|
237
286
|
}
|
|
@@ -248,14 +297,21 @@ export class DevPulseClient {
|
|
|
248
297
|
allFlags() {
|
|
249
298
|
return Object.fromEntries(Object.entries(this.flags).map(([k, v]) => [k, v.value]));
|
|
250
299
|
}
|
|
251
|
-
/**
|
|
300
|
+
/**
|
|
301
|
+
* Switch to a different context (e.g. after login). Resolves with the new flags. When calls
|
|
302
|
+
* overlap, the last one wins: an earlier call's late response is ignored.
|
|
303
|
+
*/
|
|
252
304
|
async identify(context, opts = {}) {
|
|
305
|
+
this.contextSeq++;
|
|
253
306
|
this.context = context;
|
|
254
307
|
if (opts.hash !== undefined)
|
|
255
308
|
this.options.hash = opts.hash;
|
|
309
|
+
if (this.options.offline || this.closed)
|
|
310
|
+
return this.allFlags();
|
|
256
311
|
this.events?.identify(context);
|
|
257
|
-
|
|
312
|
+
// Drop the old context's stream now, so none of its events land after the switch.
|
|
258
313
|
this.stream?.restart();
|
|
314
|
+
await this.loadFlags();
|
|
259
315
|
return this.allFlags();
|
|
260
316
|
}
|
|
261
317
|
getContext() {
|
|
@@ -265,6 +321,10 @@ export class DevPulseClient {
|
|
|
265
321
|
track(key, data, metricValue) {
|
|
266
322
|
this.events?.track(key, this.context, data, metricValue);
|
|
267
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Events: "ready", "failed" (fatal, e.g. an invalid client-side ID), "change" / "change:<key>",
|
|
326
|
+
* "stale" (stream lost; the last flags keep serving) and "recovered".
|
|
327
|
+
*/
|
|
268
328
|
on(event, fn) {
|
|
269
329
|
let set = this.listeners.get(event);
|
|
270
330
|
if (!set)
|
|
@@ -292,6 +352,8 @@ export class DevPulseClient {
|
|
|
292
352
|
if (this.closed)
|
|
293
353
|
return;
|
|
294
354
|
this.closed = true;
|
|
355
|
+
this.detachVisibility?.();
|
|
356
|
+
this.detachVisibility = null;
|
|
295
357
|
this.stream?.stop();
|
|
296
358
|
await this.events?.close();
|
|
297
359
|
this.listeners.clear();
|
|
@@ -55,5 +55,14 @@ export declare class EventProcessor {
|
|
|
55
55
|
private push;
|
|
56
56
|
private drain;
|
|
57
57
|
flush(): Promise<void>;
|
|
58
|
+
private send;
|
|
58
59
|
close(): Promise<void>;
|
|
59
60
|
}
|
|
61
|
+
/** DevPulse accepts at most this many events per POST (the rest are silently dropped). */
|
|
62
|
+
export declare const MAX_EVENTS_PER_REQUEST = 5000;
|
|
63
|
+
/** Browser event bodies are capped at 512 KB by DevPulse; stay under it with room for brackets. */
|
|
64
|
+
export declare const MAX_REQUEST_BYTES = 500000;
|
|
65
|
+
/** keepalive / sendBeacon bodies must fit in the browser's 64 KiB in-flight budget. */
|
|
66
|
+
export declare const KEEPALIVE_MAX_BYTES = 60000;
|
|
67
|
+
/** Split events into JSON array bodies of at most MAX_EVENTS_PER_REQUEST events and MAX_REQUEST_BYTES bytes. */
|
|
68
|
+
export declare function chunkEvents(events: Record<string, unknown>[], logger?: Logger, maxBytes?: number, maxEvents?: number): string[];
|
|
@@ -166,10 +166,18 @@ export class EventProcessor {
|
|
|
166
166
|
const events = this.drain();
|
|
167
167
|
if (!events.length)
|
|
168
168
|
return;
|
|
169
|
-
const body
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
for (const body of chunkEvents(events, this.logger)) {
|
|
170
|
+
if (this.disabled)
|
|
171
|
+
return;
|
|
172
|
+
await this.send(body);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async send(body) {
|
|
176
|
+
const bytes = byteLength(body);
|
|
177
|
+
const g = globalThis;
|
|
178
|
+
// Beacons and keepalive requests share a 64 KiB in-flight budget in browsers; bigger bodies fail outright.
|
|
179
|
+
if (this.opts.beacon && g.navigator?.sendBeacon && g.document?.visibilityState === "hidden" && bytes < KEEPALIVE_MAX_BYTES) {
|
|
180
|
+
if (g.navigator.sendBeacon(this.opts.url, body))
|
|
173
181
|
return;
|
|
174
182
|
}
|
|
175
183
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -178,7 +186,7 @@ export class EventProcessor {
|
|
|
178
186
|
method: "POST",
|
|
179
187
|
headers: { "content-type": "application/json", ...this.opts.headers },
|
|
180
188
|
body,
|
|
181
|
-
keepalive:
|
|
189
|
+
keepalive: bytes < KEEPALIVE_MAX_BYTES,
|
|
182
190
|
});
|
|
183
191
|
if (res.ok)
|
|
184
192
|
return;
|
|
@@ -193,7 +201,8 @@ export class EventProcessor {
|
|
|
193
201
|
if (attempt === 1)
|
|
194
202
|
this.logger.warn("Failed to send events", err);
|
|
195
203
|
}
|
|
196
|
-
|
|
204
|
+
if (attempt === 0)
|
|
205
|
+
await new Promise((r) => unrefTimer(setTimeout(r, 1000)));
|
|
197
206
|
}
|
|
198
207
|
}
|
|
199
208
|
async close() {
|
|
@@ -202,3 +211,39 @@ export class EventProcessor {
|
|
|
202
211
|
await this.flush();
|
|
203
212
|
}
|
|
204
213
|
}
|
|
214
|
+
/** DevPulse accepts at most this many events per POST (the rest are silently dropped). */
|
|
215
|
+
export const MAX_EVENTS_PER_REQUEST = 5000;
|
|
216
|
+
/** Browser event bodies are capped at 512 KB by DevPulse; stay under it with room for brackets. */
|
|
217
|
+
export const MAX_REQUEST_BYTES = 500_000;
|
|
218
|
+
/** keepalive / sendBeacon bodies must fit in the browser's 64 KiB in-flight budget. */
|
|
219
|
+
export const KEEPALIVE_MAX_BYTES = 60_000;
|
|
220
|
+
const encoder = new TextEncoder();
|
|
221
|
+
const byteLength = (s) => encoder.encode(s).length;
|
|
222
|
+
function unrefTimer(t) {
|
|
223
|
+
t.unref?.();
|
|
224
|
+
return t;
|
|
225
|
+
}
|
|
226
|
+
/** Split events into JSON array bodies of at most MAX_EVENTS_PER_REQUEST events and MAX_REQUEST_BYTES bytes. */
|
|
227
|
+
export function chunkEvents(events, logger = noopLogger, maxBytes = MAX_REQUEST_BYTES, maxEvents = MAX_EVENTS_PER_REQUEST) {
|
|
228
|
+
const bodies = [];
|
|
229
|
+
let parts = [];
|
|
230
|
+
let size = 2; // "[" + "]"
|
|
231
|
+
for (const e of events) {
|
|
232
|
+
const json = JSON.stringify(e);
|
|
233
|
+
const n = byteLength(json);
|
|
234
|
+
if (n + 2 > maxBytes) {
|
|
235
|
+
logger.warn(`Dropping a ${String(e.kind)} event of ${n} bytes (over the ${maxBytes}-byte request limit)`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (parts.length && (parts.length >= maxEvents || size + n + 1 > maxBytes)) {
|
|
239
|
+
bodies.push(`[${parts.join(",")}]`);
|
|
240
|
+
parts = [];
|
|
241
|
+
size = 2;
|
|
242
|
+
}
|
|
243
|
+
size += n + (parts.length ? 1 : 0);
|
|
244
|
+
parts.push(json);
|
|
245
|
+
}
|
|
246
|
+
if (parts.length)
|
|
247
|
+
bodies.push(`[${parts.join(",")}]`);
|
|
248
|
+
return bodies;
|
|
249
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { SseParser, SseClient, SseError, backoffDelay, type SseEvent, type SseClientOptions } from "./sse.js";
|
|
2
|
-
export { EventProcessor, noopLogger, type EventProcessorOptions, type Logger } from "./events.js";
|
|
1
|
+
export { SseParser, SseClient, SseError, backoffDelay, DEFAULT_MAX_EVENT_SIZE, MIN_SERVER_RETRY_MS, type SseEvent, type SseClientOptions } from "./sse.js";
|
|
2
|
+
export { EventProcessor, noopLogger, chunkEvents, MAX_EVENTS_PER_REQUEST, MAX_REQUEST_BYTES, KEEPALIVE_MAX_BYTES, type EventProcessorOptions, type Logger } from "./events.js";
|
|
3
3
|
/** Encode a context for URLs (base64url JSON), matching the DevPulse SDK endpoints. */
|
|
4
4
|
export declare function encodeContext(context: unknown): string;
|
|
5
|
-
export
|
|
5
|
+
export { SDK_VERSION } from "./version.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { SseParser, SseClient, SseError, backoffDelay } from "./sse.js";
|
|
2
|
-
export { EventProcessor, noopLogger } from "./events.js";
|
|
1
|
+
export { SseParser, SseClient, SseError, backoffDelay, DEFAULT_MAX_EVENT_SIZE, MIN_SERVER_RETRY_MS } from "./sse.js";
|
|
2
|
+
export { EventProcessor, noopLogger, chunkEvents, MAX_EVENTS_PER_REQUEST, MAX_REQUEST_BYTES, KEEPALIVE_MAX_BYTES } from "./events.js";
|
|
3
3
|
/** Encode a context for URLs (base64url JSON), matching the DevPulse SDK endpoints. */
|
|
4
4
|
export function encodeContext(context) {
|
|
5
5
|
const json = JSON.stringify(context);
|
|
@@ -9,4 +9,4 @@ export function encodeContext(context) {
|
|
|
9
9
|
bin += String.fromCharCode(b);
|
|
10
10
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
11
|
}
|
|
12
|
-
export
|
|
12
|
+
export { SDK_VERSION } from "./version.js";
|
|
@@ -9,16 +9,25 @@ export interface SseEvent {
|
|
|
9
9
|
data: string;
|
|
10
10
|
lastEventId: string;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
12
|
+
/** Default cap on one event (its pending line plus its `data` lines), in UTF-16 code units. */
|
|
13
|
+
export declare const DEFAULT_MAX_EVENT_SIZE: number;
|
|
14
|
+
/**
|
|
15
|
+
* Incremental parser: feed it chunks of text, get dispatched events back. Use one parser per
|
|
16
|
+
* connection: a dropped connection can leave a half-received event behind.
|
|
17
|
+
*/
|
|
13
18
|
export declare class SseParser {
|
|
19
|
+
private readonly maxEventSize;
|
|
14
20
|
private buffer;
|
|
15
21
|
private data;
|
|
22
|
+
private dataSize;
|
|
16
23
|
private eventType;
|
|
17
24
|
private sawFirstChunk;
|
|
18
25
|
/** The previous chunk ended in CR, so an LF starting this chunk completes a CRLF. */
|
|
19
26
|
private pendingCR;
|
|
20
27
|
lastEventId: string;
|
|
21
28
|
retry: number | null;
|
|
29
|
+
/** @param maxEventSize feed() throws an SseError once an event grows past this (bounded memory). */
|
|
30
|
+
constructor(maxEventSize?: number);
|
|
22
31
|
feed(chunk: string): SseEvent[];
|
|
23
32
|
private line;
|
|
24
33
|
}
|
|
@@ -35,7 +44,16 @@ export interface SseClientOptions {
|
|
|
35
44
|
maxRetryMs?: number;
|
|
36
45
|
/** Reconnect if nothing (not even a heartbeat) arrives for this long. */
|
|
37
46
|
readTimeoutMs?: number;
|
|
47
|
+
/** Largest single event accepted (default 1 MB); a bigger one drops the connection. */
|
|
48
|
+
maxEventSize?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Send only CORS-safelisted request headers: no `cache-control` or `last-event-id`, which
|
|
51
|
+
* would trigger a preflight that DevPulse doesn't allow. Default: true in browsers and workers.
|
|
52
|
+
*/
|
|
53
|
+
browser?: boolean;
|
|
38
54
|
}
|
|
55
|
+
/** Lower bound for a server-sent `retry:` hint, so `retry: 0` can't cause a reconnect storm. */
|
|
56
|
+
export declare const MIN_SERVER_RETRY_MS = 1000;
|
|
39
57
|
export declare class SseError extends Error {
|
|
40
58
|
readonly status?: number | undefined;
|
|
41
59
|
constructor(message: string, status?: number | undefined);
|
|
@@ -49,14 +67,17 @@ export declare class SseClient {
|
|
|
49
67
|
private controller;
|
|
50
68
|
private stopped;
|
|
51
69
|
private attempt;
|
|
52
|
-
|
|
70
|
+
/** Carried across connections; the parser itself is fresh per connection. */
|
|
71
|
+
private lastEventId;
|
|
72
|
+
private retry;
|
|
53
73
|
private timer;
|
|
54
74
|
private watchdog;
|
|
55
75
|
private reader;
|
|
56
76
|
constructor(opts: SseClientOptions);
|
|
57
77
|
start(): void;
|
|
58
78
|
stop(): void;
|
|
59
|
-
/** Drop the current connection and reconnect immediately (e.g. after identify). */
|
|
79
|
+
/** Drop the current connection (or pending reconnect) and reconnect immediately (e.g. after identify). */
|
|
60
80
|
restart(): void;
|
|
81
|
+
private clearTimer;
|
|
61
82
|
private connect;
|
|
62
83
|
}
|
package/dist/flags/common/sse.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Default cap on one event (its pending line plus its `data` lines), in UTF-16 code units. */
|
|
2
|
+
export const DEFAULT_MAX_EVENT_SIZE = 1024 * 1024;
|
|
3
|
+
/**
|
|
4
|
+
* Incremental parser: feed it chunks of text, get dispatched events back. Use one parser per
|
|
5
|
+
* connection: a dropped connection can leave a half-received event behind.
|
|
6
|
+
*/
|
|
2
7
|
export class SseParser {
|
|
8
|
+
maxEventSize;
|
|
3
9
|
buffer = "";
|
|
4
10
|
data = [];
|
|
11
|
+
dataSize = 0;
|
|
5
12
|
eventType = "";
|
|
6
13
|
sawFirstChunk = false;
|
|
7
14
|
/** The previous chunk ended in CR, so an LF starting this chunk completes a CRLF. */
|
|
8
15
|
pendingCR = false;
|
|
9
16
|
lastEventId = "";
|
|
10
17
|
retry = null;
|
|
18
|
+
/** @param maxEventSize feed() throws an SseError once an event grows past this (bounded memory). */
|
|
19
|
+
constructor(maxEventSize = DEFAULT_MAX_EVENT_SIZE) {
|
|
20
|
+
this.maxEventSize = maxEventSize;
|
|
21
|
+
}
|
|
11
22
|
feed(chunk) {
|
|
12
23
|
if (!this.sawFirstChunk) {
|
|
13
24
|
this.sawFirstChunk = true;
|
|
@@ -39,6 +50,12 @@ export class SseParser {
|
|
|
39
50
|
out.push(ev);
|
|
40
51
|
}
|
|
41
52
|
this.buffer = this.buffer.slice(start);
|
|
53
|
+
if (this.buffer.length + this.dataSize > this.maxEventSize) {
|
|
54
|
+
this.buffer = "";
|
|
55
|
+
this.data = [];
|
|
56
|
+
this.dataSize = 0;
|
|
57
|
+
throw new SseError(`Stream event exceeded ${this.maxEventSize} characters`);
|
|
58
|
+
}
|
|
42
59
|
return out;
|
|
43
60
|
}
|
|
44
61
|
line(line) {
|
|
@@ -49,6 +66,7 @@ export class SseParser {
|
|
|
49
66
|
}
|
|
50
67
|
const ev = { type: this.eventType || "message", data: this.data.join("\n"), lastEventId: this.lastEventId };
|
|
51
68
|
this.data = [];
|
|
69
|
+
this.dataSize = 0;
|
|
52
70
|
this.eventType = "";
|
|
53
71
|
return ev;
|
|
54
72
|
}
|
|
@@ -65,6 +83,12 @@ export class SseParser {
|
|
|
65
83
|
break;
|
|
66
84
|
case "data":
|
|
67
85
|
this.data.push(value);
|
|
86
|
+
this.dataSize += value.length + 1;
|
|
87
|
+
if (this.dataSize > this.maxEventSize) {
|
|
88
|
+
this.data = [];
|
|
89
|
+
this.dataSize = 0;
|
|
90
|
+
throw new SseError(`Stream event exceeded ${this.maxEventSize} characters`);
|
|
91
|
+
}
|
|
68
92
|
break;
|
|
69
93
|
case "id":
|
|
70
94
|
if (!value.includes("\0"))
|
|
@@ -78,6 +102,18 @@ export class SseParser {
|
|
|
78
102
|
return null;
|
|
79
103
|
}
|
|
80
104
|
}
|
|
105
|
+
/** Lower bound for a server-sent `retry:` hint, so `retry: 0` can't cause a reconnect storm. */
|
|
106
|
+
export const MIN_SERVER_RETRY_MS = 1000;
|
|
107
|
+
/** Browsers and web workers enforce CORS on fetch; Node, Deno (no document), Bun and React Native don't. */
|
|
108
|
+
function isBrowserLike() {
|
|
109
|
+
const g = globalThis;
|
|
110
|
+
return typeof g.document !== "undefined" || typeof g.WorkerGlobalScope !== "undefined";
|
|
111
|
+
}
|
|
112
|
+
/** Let a timer not keep a Node/Deno/Bun process alive (no-op in browsers). */
|
|
113
|
+
function unref(t) {
|
|
114
|
+
t.unref?.();
|
|
115
|
+
return t;
|
|
116
|
+
}
|
|
81
117
|
export class SseError extends Error {
|
|
82
118
|
status;
|
|
83
119
|
constructor(message, status) {
|
|
@@ -99,7 +135,9 @@ export class SseClient {
|
|
|
99
135
|
controller = null;
|
|
100
136
|
stopped = false;
|
|
101
137
|
attempt = 0;
|
|
102
|
-
parser
|
|
138
|
+
/** Carried across connections; the parser itself is fresh per connection. */
|
|
139
|
+
lastEventId = "";
|
|
140
|
+
retry = null;
|
|
103
141
|
timer = null;
|
|
104
142
|
watchdog = null;
|
|
105
143
|
reader = null;
|
|
@@ -112,44 +150,66 @@ export class SseClient {
|
|
|
112
150
|
}
|
|
113
151
|
stop() {
|
|
114
152
|
this.stopped = true;
|
|
115
|
-
|
|
116
|
-
clearTimeout(this.timer);
|
|
153
|
+
this.clearTimer();
|
|
117
154
|
// Don't leave the read-timeout timer or a pending read keeping the process alive.
|
|
118
155
|
if (this.watchdog)
|
|
119
156
|
clearTimeout(this.watchdog);
|
|
157
|
+
this.watchdog = null;
|
|
120
158
|
this.controller?.abort();
|
|
121
159
|
void this.reader?.cancel().catch(() => { });
|
|
122
160
|
}
|
|
123
|
-
/** Drop the current connection and reconnect immediately (e.g. after identify). */
|
|
161
|
+
/** Drop the current connection (or pending reconnect) and reconnect immediately (e.g. after identify). */
|
|
124
162
|
restart() {
|
|
163
|
+
this.clearTimer();
|
|
125
164
|
this.controller?.abort();
|
|
126
165
|
this.attempt = 0;
|
|
127
|
-
this.
|
|
166
|
+
this.lastEventId = ""; // the old stream's ids mean nothing to a new context
|
|
128
167
|
if (!this.stopped)
|
|
129
168
|
void this.connect();
|
|
130
169
|
}
|
|
170
|
+
clearTimer() {
|
|
171
|
+
if (this.timer)
|
|
172
|
+
clearTimeout(this.timer);
|
|
173
|
+
this.timer = null;
|
|
174
|
+
}
|
|
131
175
|
async connect() {
|
|
132
176
|
if (this.stopped)
|
|
133
177
|
return;
|
|
178
|
+
this.clearTimer();
|
|
179
|
+
this.controller?.abort(); // never two live connections
|
|
134
180
|
const controller = new AbortController();
|
|
135
181
|
this.controller = controller;
|
|
182
|
+
const parser = new SseParser(this.opts.maxEventSize);
|
|
183
|
+
parser.lastEventId = this.lastEventId;
|
|
184
|
+
parser.retry = this.retry;
|
|
136
185
|
const f = this.opts.fetch ?? fetch;
|
|
137
186
|
const resetWatchdog = () => {
|
|
138
187
|
if (this.watchdog)
|
|
139
188
|
clearTimeout(this.watchdog);
|
|
140
|
-
this.watchdog = setTimeout(() => controller.abort(), this.opts.readTimeoutMs ?? 5 * 60_000);
|
|
189
|
+
this.watchdog = unref(setTimeout(() => controller.abort(), this.opts.readTimeoutMs ?? 5 * 60_000));
|
|
141
190
|
};
|
|
142
191
|
const connectedAt = Date.now();
|
|
143
192
|
try {
|
|
144
193
|
const url = typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
|
|
145
194
|
const headers = typeof this.opts.headers === "function" ? this.opts.headers() : (this.opts.headers ?? {});
|
|
195
|
+
const browser = this.opts.browser ?? isBrowserLike();
|
|
146
196
|
resetWatchdog();
|
|
147
197
|
const res = await f(url, {
|
|
148
|
-
headers: {
|
|
198
|
+
headers: {
|
|
199
|
+
accept: "text/event-stream",
|
|
200
|
+
...(browser ? {} : { "cache-control": "no-cache", ...(this.lastEventId ? { "last-event-id": this.lastEventId } : {}) }),
|
|
201
|
+
...headers,
|
|
202
|
+
},
|
|
203
|
+
// Browsers: keep the HTTP cache out of it without a non-safelisted request header.
|
|
204
|
+
...(browser ? { cache: "no-store" } : {}),
|
|
149
205
|
signal: controller.signal,
|
|
150
206
|
});
|
|
151
207
|
if (!res.ok || !res.body)
|
|
152
208
|
throw new SseError(`Stream request failed with ${res.status}`, res.status);
|
|
209
|
+
if (this.controller !== controller) {
|
|
210
|
+
void res.body.cancel().catch(() => { });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
153
213
|
this.opts.onOpen?.();
|
|
154
214
|
const reader = res.body.getReader();
|
|
155
215
|
this.reader = reader;
|
|
@@ -159,25 +219,35 @@ export class SseClient {
|
|
|
159
219
|
if (done)
|
|
160
220
|
break;
|
|
161
221
|
resetWatchdog();
|
|
162
|
-
|
|
222
|
+
const events = parser.feed(decoder.decode(value, { stream: true }));
|
|
223
|
+
this.lastEventId = parser.lastEventId;
|
|
224
|
+
this.retry = parser.retry;
|
|
225
|
+
for (const ev of events) {
|
|
226
|
+
if (this.controller !== controller)
|
|
227
|
+
return; // superseded by restart()/stop() from a handler
|
|
163
228
|
this.opts.onEvent(ev);
|
|
229
|
+
}
|
|
164
230
|
}
|
|
165
231
|
throw new SseError("Stream closed by server");
|
|
166
232
|
}
|
|
167
233
|
catch (err) {
|
|
168
|
-
if (this.watchdog)
|
|
234
|
+
if (this.controller === controller && this.watchdog)
|
|
169
235
|
clearTimeout(this.watchdog);
|
|
170
|
-
if (this.stopped ||
|
|
236
|
+
if (this.stopped || this.controller !== controller)
|
|
171
237
|
return;
|
|
238
|
+
controller.abort(); // release the response body
|
|
172
239
|
const e = err instanceof SseError ? err : new SseError(err?.message ?? "Stream error");
|
|
173
240
|
const keepGoing = this.opts.onError?.(e);
|
|
174
|
-
if (keepGoing === false || !e.recoverable)
|
|
241
|
+
if (keepGoing === false || !e.recoverable || this.stopped || this.controller !== controller)
|
|
175
242
|
return;
|
|
176
243
|
if (Date.now() - connectedAt > 60_000)
|
|
177
244
|
this.attempt = 0; // was healthy for a while
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
245
|
+
const fromServer = this.retry !== null;
|
|
246
|
+
const base = fromServer ? Math.max(MIN_SERVER_RETRY_MS, this.retry) : (this.opts.initialRetryMs ?? 1000);
|
|
247
|
+
let delay = backoffDelay(this.attempt++, base, this.opts.maxRetryMs ?? 30_000);
|
|
248
|
+
if (fromServer)
|
|
249
|
+
delay = Math.max(MIN_SERVER_RETRY_MS, delay);
|
|
250
|
+
this.timer = unref(setTimeout(() => void this.connect(), delay));
|
|
181
251
|
}
|
|
182
252
|
}
|
|
183
253
|
}
|
|
@@ -2,6 +2,6 @@ export * from "./types.js";
|
|
|
2
2
|
export { parseContext, getAttribute, ContextError, type Context, type ContextInput, type SingleContext } from "./context.js";
|
|
3
3
|
export { Evaluator, MemoryStore, evaluateFlag, errorDetail, type EvaluationResult, type PrerequisiteEvaluation } from "./evaluate.js";
|
|
4
4
|
export { bucketContext, variationForRollout } from "./bucket.js";
|
|
5
|
-
export { applyOperator, parseTime } from "./operators.js";
|
|
5
|
+
export { applyOperator, parseTime, isSafePattern, compilePattern, MAX_PATTERN_LENGTH, MAX_MATCH_INPUT, MAX_MATCH_PATTERN_LENGTH, MAX_MATCH_INPUT_LENGTH, type SafeRegex } from "./operators.js";
|
|
6
6
|
export { parseSemVer, compareSemVer } from "./semver.js";
|
|
7
7
|
export { sha1Hex } from "./sha1.js";
|
|
@@ -2,6 +2,6 @@ export * from "./types.js";
|
|
|
2
2
|
export { parseContext, getAttribute, ContextError } from "./context.js";
|
|
3
3
|
export { Evaluator, MemoryStore, evaluateFlag, errorDetail } from "./evaluate.js";
|
|
4
4
|
export { bucketContext, variationForRollout } from "./bucket.js";
|
|
5
|
-
export { applyOperator, parseTime } from "./operators.js";
|
|
5
|
+
export { applyOperator, parseTime, isSafePattern, compilePattern, MAX_PATTERN_LENGTH, MAX_MATCH_INPUT, MAX_MATCH_PATTERN_LENGTH, MAX_MATCH_INPUT_LENGTH } from "./operators.js";
|
|
6
6
|
export { parseSemVer, compareSemVer } from "./semver.js";
|
|
7
7
|
export { sha1Hex } from "./sha1.js";
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import type { FlagValue, Operator } from "./types.js";
|
|
2
|
-
/** `matches` limits: longer inputs or patterns never match. */
|
|
2
|
+
/** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
|
|
3
3
|
export declare const MAX_MATCH_INPUT_LENGTH = 1024;
|
|
4
4
|
export declare const MAX_MATCH_PATTERN_LENGTH = 500;
|
|
5
|
+
/** Server-parity aliases (`@devpulse/flag-engine` names). */
|
|
6
|
+
export declare const MAX_PATTERN_LENGTH = 500;
|
|
7
|
+
export declare const MAX_MATCH_INPUT = 1024;
|
|
8
|
+
/** A compiled user pattern: only `test()` is exposed. */
|
|
9
|
+
export type SafeRegex = {
|
|
10
|
+
test(input: string): boolean;
|
|
11
|
+
};
|
|
5
12
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
13
|
+
* Compile a user-supplied pattern with RE2 (linear-time matching, no backtracking), so no
|
|
14
|
+
* pattern can stall the process however it's written (ReDoS), and every SDK agrees with the
|
|
15
|
+
* DevPulse server on what matches. RE2 has no backreferences or lookaround; patterns using
|
|
16
|
+
* them don't compile and return null (never match).
|
|
17
|
+
*/
|
|
18
|
+
export declare function compilePattern(pattern: string): SafeRegex | null;
|
|
19
|
+
/** Whether a `matches` pattern is accepted: bounded length and valid RE2 syntax. */
|
|
20
|
+
export declare function isSafePattern(pattern: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* @deprecated No longer used: `matches` runs on RE2, which is immune to ReDoS. Kept for
|
|
23
|
+
* backwards compatibility. Conservative heuristic for a group that contains a quantifier or
|
|
24
|
+
* alternation and is itself repeated (`(a+)+`, `(a|aa)+`, `(a+){2,}`).
|
|
8
25
|
*/
|
|
9
26
|
export declare function hasNestedQuantifier(pattern: string): boolean;
|
|
10
27
|
/** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
|