Skip to content

Commit 9a0fd22

Browse files
kwasniewnunogois
andauthored
feat: streaming poc (#679)
Co-authored-by: Nuno Góis <[email protected]>
1 parent 4115c39 commit 9a0fd22

File tree

8 files changed

+147
-4
lines changed

8 files changed

+147
-4
lines changed

examples/streaming.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const { Unleash } = require('../lib');
2+
3+
const client = new Unleash({
4+
appName: 'my-application',
5+
url: 'https://app.unleash-hosted.com/demo/api/',
6+
customHeaders: {
7+
Authorization: '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0',
8+
},
9+
experimentalMode: { type: 'streaming' },
10+
skipInstanceCountWarning: true,
11+
});
12+
client.on('changed', () => {
13+
console.log(client.isEnabled('demo001', { userId: `${Math.random()}` }));
14+
});

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "unleash-client",
3-
"version": "6.2.0",
3+
"version": "6.3.0-alpha.0",
44
"description": "Unleash Client for Node",
55
"license": "Apache-2.0",
66
"main": "./lib/index.js",
@@ -33,6 +33,7 @@
3333
"http-proxy-agent": "^7.0.2",
3434
"https-proxy-agent": "^7.0.5",
3535
"ip-address": "^9.0.5",
36+
"launchdarkly-eventsource": "2.0.3",
3637
"make-fetch-happen": "^13.0.1",
3738
"murmurhash3js": "^3.0.1",
3839
"proxy-from-env": "^1.1.0",
@@ -50,6 +51,7 @@
5051
"@ava/babel": "^2.0.0",
5152
"@ava/typescript": "^4.0.0",
5253
"@tsconfig/node12": "^12.0.0",
54+
"@types/eventsource": "^1.1.15",
5355
"@types/express": "^4.17.17",
5456
"@types/jsbn": "^1.2.33",
5557
"@types/make-fetch-happen": "^10.0.4",

src/details.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"name":"unleash-client-node","version":"6.2.0","sdkVersion":"unleash-client-node:6.2.0"}
1+
{"name":"unleash-client-node","version":"6.3.0-alpha.0","sdkVersion":"unleash-client-node:6.3.0-alpha.0"}

src/repository/index.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
Segment,
1414
StrategyTransportInterface,
1515
} from '../strategy/strategy';
16+
// @ts-expect-error
17+
import { EventSource } from 'launchdarkly-eventsource';
1618

1719
export const SUPPORTED_SPEC_VERSION = '4.3.0';
1820

@@ -39,6 +41,7 @@ export interface RepositoryOptions {
3941
bootstrapProvider: BootstrapProvider;
4042
bootstrapOverride?: boolean;
4143
storageProvider: StorageProvider<ClientFeaturesResponse>;
44+
eventSource?: EventSource;
4245
}
4346

4447
interface FeatureToggleData {
@@ -90,6 +93,8 @@ export default class Repository extends EventEmitter implements EventEmitter {
9093

9194
private segments: Map<number, Segment>;
9295

96+
private eventSource: EventSource | undefined;
97+
9398
constructor({
9499
url,
95100
appName,
@@ -105,6 +110,7 @@ export default class Repository extends EventEmitter implements EventEmitter {
105110
bootstrapProvider,
106111
bootstrapOverride = true,
107112
storageProvider,
113+
eventSource,
108114
}: RepositoryOptions) {
109115
super();
110116
this.url = url;
@@ -122,10 +128,30 @@ export default class Repository extends EventEmitter implements EventEmitter {
122128
this.bootstrapOverride = bootstrapOverride;
123129
this.storageProvider = storageProvider;
124130
this.segments = new Map();
131+
this.eventSource = eventSource;
132+
if (this.eventSource) {
133+
this.eventSource.addEventListener('unleash-updated', (event: { data: string }) => {
134+
try {
135+
const data: ClientFeaturesResponse & { meta: { etag: string } } = JSON.parse(event.data);
136+
const etag = data.meta.etag;
137+
if (etag !== null) {
138+
this.etag = etag;
139+
} else {
140+
this.etag = undefined;
141+
}
142+
this.save(data, true);
143+
} catch (err) {
144+
this.emit(UnleashEvents.Error, err);
145+
}
146+
});
147+
this.eventSource.addEventListener('error', (error: unknown) => {
148+
this.emit(UnleashEvents.Warn, error);
149+
});
150+
}
125151
}
126152

127153
timedFetch(interval: number) {
128-
if (interval > 0) {
154+
if (interval > 0 && !this.eventSource) {
129155
this.timer = setTimeout(() => this.fetch(), interval);
130156
if (process.env.NODE_ENV !== 'test' && typeof this.timer.unref === 'function') {
131157
this.timer.unref();
@@ -398,6 +424,9 @@ Message: ${err.message}`,
398424
clearTimeout(this.timer);
399425
}
400426
this.removeAllListeners();
427+
if (this.eventSource) {
428+
this.eventSource.close();
429+
}
401430
}
402431

403432
getSegment(segmentId: number): Segment | undefined {

src/test/repository.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Repository from '../repository';
1010
import { DefaultBootstrapProvider } from '../repository/bootstrap-provider';
1111
import { StorageProvider } from '../repository/storage-provider';
1212
import { ClientFeaturesResponse } from '../feature';
13+
import { EventEmitter } from 'events';
1314

1415
const appName = 'foo';
1516
const instanceId = 'bar';
@@ -1360,3 +1361,73 @@ test('Stopping repository should stop storage provider updates', async (t) => {
13601361
const result = await storageProvider.get(appName);
13611362
t.is(result, undefined);
13621363
});
1364+
1365+
test('Streaming', async (t) => {
1366+
t.plan(6);
1367+
const url = 'irrelevant';
1368+
const feature = {
1369+
name: 'feature',
1370+
enabled: true,
1371+
strategies: [
1372+
{
1373+
name: 'default',
1374+
},
1375+
],
1376+
};
1377+
const storageProvider: StorageProvider<ClientFeaturesResponse> = new InMemStorageProvider();
1378+
const eventSource = {
1379+
eventEmitter: new EventEmitter(),
1380+
listeners: new Set<string>(),
1381+
addEventListener(eventName: string, handler: () => void) {
1382+
eventSource.listeners.add(eventName);
1383+
eventSource.eventEmitter.on(eventName, handler);
1384+
},
1385+
close() {
1386+
eventSource.listeners.forEach((eventName) => {
1387+
eventSource.eventEmitter.removeAllListeners(eventName);
1388+
});
1389+
},
1390+
emit(eventName: string, data: unknown) {
1391+
eventSource.eventEmitter.emit(eventName, data);
1392+
},
1393+
};
1394+
const repo = new Repository({
1395+
url,
1396+
appName,
1397+
instanceId,
1398+
refreshInterval: 10,
1399+
// @ts-expect-error
1400+
bootstrapProvider: new DefaultBootstrapProvider({}),
1401+
storageProvider,
1402+
eventSource,
1403+
});
1404+
1405+
const before = repo.getToggles();
1406+
t.deepEqual(before, []);
1407+
1408+
// update with feature
1409+
eventSource.emit('unleash-updated', {
1410+
type: 'unleash-updated',
1411+
data: JSON.stringify({ meta: {}, features: [feature] }),
1412+
});
1413+
const firstUpdate = repo.getToggles();
1414+
t.deepEqual(firstUpdate, [feature]);
1415+
// @ts-expect-error
1416+
t.is(repo.etag, undefined);
1417+
1418+
// update with etag
1419+
eventSource.emit('unleash-updated', {
1420+
type: 'unleash-updated',
1421+
data: JSON.stringify({ meta: { etag: 'updated' }, features: [] }),
1422+
});
1423+
const secondUpdate = repo.getToggles();
1424+
t.deepEqual(secondUpdate, []);
1425+
// @ts-expect-error
1426+
t.is(repo.etag, 'updated');
1427+
1428+
// SSE error translated to repo warning
1429+
repo.on('warn', (msg) => {
1430+
t.is(msg, 'some error');
1431+
});
1432+
eventSource.emit('error', 'some error');
1433+
});

src/unleash-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { BootstrapOptions } from './repository/bootstrap-provider';
88
import { StorageProvider } from './repository/storage-provider';
99
import { RepositoryInterface } from './repository';
1010

11+
export type Mode = { type: 'polling' } | { type: 'streaming' };
12+
1113
export interface UnleashConfig {
1214
appName: string;
1315
environment?: string;
@@ -32,4 +34,5 @@ export interface UnleashConfig {
3234
storageProvider?: StorageProvider<ClientFeaturesResponse>;
3335
disableAutoStart?: boolean;
3436
skipInstanceCountWarning?: boolean;
37+
experimentalMode?: Mode;
3538
}

src/unleash.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import { resolveBootstrapProvider } from './repository/bootstrap-provider';
1818
import { ImpressionEvent, UnleashEvents } from './events';
1919
import { UnleashConfig } from './unleash-config';
2020
import FileStorageProvider from './repository/storage-provider-file';
21-
21+
import { resolveUrl } from './url-utils';
22+
// @ts-expect-error
23+
import { EventSource } from 'launchdarkly-eventsource';
2224
export { Strategy, UnleashEvents, UnleashConfig };
2325

2426
const BACKUP_PATH: string = tmpdir();
@@ -73,6 +75,7 @@ export class Unleash extends EventEmitter {
7375
storageProvider,
7476
disableAutoStart = false,
7577
skipInstanceCountWarning = false,
78+
experimentalMode = { type: 'polling' },
7679
}: UnleashConfig) {
7780
super();
7881

@@ -123,6 +126,17 @@ export class Unleash extends EventEmitter {
123126
tags,
124127
bootstrapProvider,
125128
bootstrapOverride,
129+
eventSource:
130+
experimentalMode?.type === 'streaming'
131+
? new EventSource(resolveUrl(unleashUrl, './client/streaming'), {
132+
headers: customHeaders,
133+
readTimeoutMillis: 60000, // start a new SSE connection when no heartbeat received in 1 minute
134+
initialRetryDelayMillis: 2000,
135+
maxBackoffMillis: 30000,
136+
retryResetIntervalMillis: 60000,
137+
jitterRatio: 0.5,
138+
})
139+
: undefined,
126140
storageProvider: storageProvider || new FileStorageProvider(backupPath),
127141
});
128142

yarn.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,11 @@
635635
dependencies:
636636
"@types/node" "*"
637637

638+
"@types/eventsource@^1.1.15":
639+
version "1.1.15"
640+
resolved "https://registry.yarnpkg.com/@types/eventsource/-/eventsource-1.1.15.tgz#949383d3482e20557cbecbf3b038368d94b6be27"
641+
integrity sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==
642+
638643
"@types/express-serve-static-core@^4.17.33":
639644
version "4.17.41"
640645
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6"
@@ -3311,6 +3316,11 @@ kind-of@^6.0.3:
33113316
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
33123317
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
33133318

3319+
3320+
version "2.0.3"
3321+
resolved "https://registry.yarnpkg.com/launchdarkly-eventsource/-/launchdarkly-eventsource-2.0.3.tgz#8a7b8da5538153f438f7d452b1c87643d900f984"
3322+
integrity sha512-VhFjppK7jXlcEKaS7bxdoibB5j01NKyeDR7a8XfssdDGNWCTsbF0/5IExSmPi44eDncPhkoPNxlSZhEZvrbD5w==
3323+
33143324
lcov-parse@^1.0.0:
33153325
version "1.0.0"
33163326
resolved "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz"

0 commit comments

Comments
 (0)