@statsig/js-client 0.0.1-beta.10

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.
@@ -0,0 +1,67 @@
1
+ import fetchMock from 'jest-fetch-mock';
2
+
3
+ import {
4
+ DataAdapterCachePrefix,
5
+ getUserStorageKey,
6
+ } from '@statsig/client-core';
7
+
8
+ import StatsigClient from '../StatsigClient';
9
+ import { MockLocalStorage } from './MockLocalStorage';
10
+ import InitializeResponse from './initialize.json';
11
+
12
+ describe('Init Strategy - Bootstrap', () => {
13
+ const sdkKey = 'client-key';
14
+ const user = { userID: 'a-user' };
15
+ const cacheKey = `${DataAdapterCachePrefix}.evaluations.${getUserStorageKey(sdkKey, user)}`;
16
+
17
+ let client: StatsigClient;
18
+ let storageMock: MockLocalStorage;
19
+
20
+ beforeAll(async () => {
21
+ storageMock = MockLocalStorage.enabledMockStorage();
22
+ storageMock.clear();
23
+
24
+ fetchMock.enableMocks();
25
+ fetchMock.mockResponse(JSON.stringify(InitializeResponse));
26
+
27
+ client = new StatsigClient(sdkKey, user);
28
+ client.dataAdapter.setData(JSON.stringify(InitializeResponse), user);
29
+
30
+ client.initializeSync();
31
+ });
32
+
33
+ afterAll(() => {
34
+ MockLocalStorage.disableMockStorage();
35
+ });
36
+
37
+ it('is ready after initialize', () => {
38
+ expect(client.loadingStatus).toBe('Ready');
39
+ });
40
+
41
+ it('reports source as "Bootstrap"', () => {
42
+ const gate = client.getFeatureGate('a_gate');
43
+ expect(gate.details.reason).toBe('Bootstrap:Recognized');
44
+ });
45
+
46
+ it('writes the updated values to cache', () => {
47
+ expect(storageMock.data[cacheKey]).toBeDefined();
48
+ });
49
+
50
+ describe('the next session', () => {
51
+ beforeAll(async () => {
52
+ fetchMock.mockClear();
53
+
54
+ client = new StatsigClient(sdkKey, user);
55
+ client.initializeSync();
56
+ });
57
+
58
+ it('is ready after initialize', () => {
59
+ expect(client.loadingStatus).toBe('Ready');
60
+ });
61
+
62
+ it('reports source as "Cache"', () => {
63
+ const gate = client.getFeatureGate('a_gate');
64
+ expect(gate.details.reason).toBe('Cache:Recognized');
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,65 @@
1
+ import fetchMock from 'jest-fetch-mock';
2
+
3
+ import {
4
+ DataAdapterCachePrefix,
5
+ getUserStorageKey,
6
+ } from '@statsig/client-core';
7
+
8
+ import StatsigClient from '../StatsigClient';
9
+ import { MockLocalStorage } from './MockLocalStorage';
10
+ import InitializeResponse from './initialize.json';
11
+
12
+ describe('Init Strategy - Delayed', () => {
13
+ const sdkKey = 'client-key';
14
+ const user = { userID: 'a-user' };
15
+ const cacheKey = `${DataAdapterCachePrefix}.evaluations.${getUserStorageKey(sdkKey, user)}`;
16
+
17
+ let client: StatsigClient;
18
+ let storageMock: MockLocalStorage;
19
+
20
+ beforeAll(async () => {
21
+ storageMock = MockLocalStorage.enabledMockStorage();
22
+ storageMock.clear();
23
+
24
+ fetchMock.enableMocks();
25
+ fetchMock.mockResponse(JSON.stringify(InitializeResponse));
26
+
27
+ client = new StatsigClient(sdkKey, user);
28
+ client.initializeSync();
29
+ });
30
+
31
+ afterAll(() => {
32
+ MockLocalStorage.disableMockStorage();
33
+ });
34
+
35
+ it('is ready after initialize', () => {
36
+ expect(client.loadingStatus).toBe('Ready');
37
+ });
38
+
39
+ it('reports source as "NoValues"', () => {
40
+ const gate = client.getFeatureGate('a_gate');
41
+ expect(gate.details.reason).toBe('NoValues');
42
+ });
43
+
44
+ it('writes the updated values to cache', () => {
45
+ expect(storageMock.data[cacheKey]).toBeDefined();
46
+ });
47
+
48
+ describe('the next session', () => {
49
+ beforeAll(async () => {
50
+ fetchMock.mockClear();
51
+
52
+ client = new StatsigClient(sdkKey, user);
53
+ client.initializeSync();
54
+ });
55
+
56
+ it('is ready after initialize', () => {
57
+ expect(client.loadingStatus).toBe('Ready');
58
+ });
59
+
60
+ it('reports source as "Cache"', () => {
61
+ const gate = client.getFeatureGate('a_gate');
62
+ expect(gate.details.reason).toBe('Cache:Recognized');
63
+ });
64
+ });
65
+ });
@@ -0,0 +1,77 @@
1
+ import fetchMock from 'jest-fetch-mock';
2
+
3
+ import { DataAdapterCachePrefix, LogLevel } from '@statsig/client-core';
4
+
5
+ import StatsigClient from '../StatsigClient';
6
+ import { MockLocalStorage } from './MockLocalStorage';
7
+ import InitializeResponse from './initialize.json';
8
+
9
+ describe('Initialize Network Bad Response', () => {
10
+ const sdkKey = 'client-key';
11
+ const user = { userID: 'a-user' };
12
+
13
+ let client: StatsigClient;
14
+ let storageMock: MockLocalStorage;
15
+
16
+ async function initialize() {
17
+ client = new StatsigClient(sdkKey, user, {
18
+ logLevel: LogLevel.None,
19
+ });
20
+ await client.initializeAsync();
21
+ }
22
+
23
+ beforeAll(async () => {
24
+ storageMock = MockLocalStorage.enabledMockStorage();
25
+ storageMock.clear();
26
+
27
+ fetchMock.enableMocks();
28
+ fetchMock.mockResponse('<NOT JSON>');
29
+ });
30
+
31
+ afterAll(() => {
32
+ MockLocalStorage.disableMockStorage();
33
+ });
34
+
35
+ describe('No Cache', () => {
36
+ beforeAll(async () => {
37
+ fetchMock.mock.calls = [];
38
+
39
+ await initialize();
40
+ });
41
+
42
+ it('is ready after initialize', () => {
43
+ expect(client.loadingStatus).toBe('Ready');
44
+ });
45
+
46
+ it('reports source as "NoValues"', () => {
47
+ const gate = client.getFeatureGate('a_gate');
48
+ expect(gate.details.reason).toBe('NoValues');
49
+ });
50
+
51
+ it('writes nothing to storage', () => {
52
+ expect(storageMock.data).toMatchObject({});
53
+ });
54
+ });
55
+
56
+ describe('With Cache', () => {
57
+ beforeAll(async () => {
58
+ fetchMock.mock.calls = [];
59
+
60
+ storageMock.setItem(
61
+ `${DataAdapterCachePrefix}.evaluations.2442570830`,
62
+ JSON.stringify({
63
+ source: 'Network',
64
+ data: JSON.stringify(InitializeResponse),
65
+ receivedAt: Date.now(),
66
+ }),
67
+ );
68
+
69
+ await initialize();
70
+ });
71
+
72
+ it('reports source as "Cache"', () => {
73
+ const gate = client.getFeatureGate('a_gate');
74
+ expect(gate.details.reason).toBe('Cache:Recognized');
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,91 @@
1
+ import fetchMock from 'jest-fetch-mock';
2
+
3
+ import { DataAdapterCachePrefix, LogLevel } from '@statsig/client-core';
4
+
5
+ import StatsigClient from '../StatsigClient';
6
+ import { MockLocalStorage } from './MockLocalStorage';
7
+ import InitializeResponse from './initialize.json';
8
+
9
+ describe('Initialize Network Failure', () => {
10
+ const sdkKey = 'client-key';
11
+ const user = { userID: 'a-user' };
12
+
13
+ let client: StatsigClient;
14
+ let storageMock: MockLocalStorage;
15
+
16
+ async function runInitAsync() {
17
+ client = new StatsigClient(sdkKey, user, {
18
+ logLevel: LogLevel.None,
19
+ });
20
+ await client.initializeAsync();
21
+ }
22
+
23
+ beforeAll(async () => {
24
+ storageMock = MockLocalStorage.enabledMockStorage();
25
+ storageMock.clear();
26
+
27
+ fetchMock.enableMocks();
28
+ fetchMock.mockReject();
29
+ });
30
+
31
+ afterAll(() => {
32
+ MockLocalStorage.disableMockStorage();
33
+ });
34
+
35
+ describe('No Cache', () => {
36
+ beforeAll(async () => {
37
+ fetchMock.mock.calls = [];
38
+
39
+ await runInitAsync();
40
+ });
41
+
42
+ it('is ready after initialize', () => {
43
+ expect(client.loadingStatus).toBe('Ready');
44
+ });
45
+
46
+ it('reports source as "NoValues"', () => {
47
+ const gate = client.getFeatureGate('a_gate');
48
+ expect(gate.details.reason).toBe('NoValues');
49
+ });
50
+
51
+ it('tries to call /initialize 3 times', () => {
52
+ expect(fetchMock.mock.calls).toHaveLength(3);
53
+ expect(fetchMock.mock.calls[0][0]).toContain(
54
+ 'https://api.statsig.com/v1/initialize',
55
+ );
56
+ });
57
+
58
+ it('writes nothing to storage', () => {
59
+ expect(storageMock.data).toMatchObject({});
60
+ });
61
+ });
62
+
63
+ describe('With Cache', () => {
64
+ beforeAll(async () => {
65
+ fetchMock.mock.calls = [];
66
+
67
+ storageMock.setItem(
68
+ `${DataAdapterCachePrefix}.evaluations.2442570830`,
69
+ JSON.stringify({
70
+ source: 'Network',
71
+ data: JSON.stringify(InitializeResponse),
72
+ receivedAt: Date.now(),
73
+ }),
74
+ );
75
+
76
+ await runInitAsync();
77
+ });
78
+
79
+ it('reports source as "Cache"', () => {
80
+ const gate = client.getFeatureGate('a_gate');
81
+ expect(gate.details.reason).toBe('Cache:Recognized');
82
+ });
83
+
84
+ it('tries to call /initialize 3 times', () => {
85
+ expect(fetchMock.mock.calls).toHaveLength(3);
86
+ expect(fetchMock.mock.calls[0][0]).toContain(
87
+ 'https://api.statsig.com/v1/initialize',
88
+ );
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,38 @@
1
+ const actualLocalStorage = window.localStorage;
2
+
3
+ // todo: move to test-helpers (break circular dep)
4
+ export class MockLocalStorage {
5
+ data: Record<string, string> = {};
6
+
7
+ static enabledMockStorage(): MockLocalStorage {
8
+ const value = new MockLocalStorage();
9
+ Object.defineProperty(window, 'localStorage', {
10
+ value,
11
+ });
12
+ return value;
13
+ }
14
+
15
+ static disableMockStorage(): void {
16
+ Object.defineProperty(window, 'localStorage', {
17
+ value: actualLocalStorage,
18
+ });
19
+ }
20
+
21
+ // LocalStorage Functions
22
+
23
+ clear(): void {
24
+ this.data = {};
25
+ }
26
+
27
+ getItem(key: string): string | null {
28
+ return this.data[key] ? this.data[key] : null;
29
+ }
30
+
31
+ setItem(key: string, value: string): void {
32
+ this.data[key] = String(value);
33
+ }
34
+
35
+ removeItem(key: string): void {
36
+ delete this.data[key];
37
+ }
38
+ }
@@ -0,0 +1,27 @@
1
+ import fetchMock from 'jest-fetch-mock';
2
+
3
+ import StatsigClient from '../StatsigClient';
4
+
5
+ describe('StatsigClient', () => {
6
+ let client: StatsigClient;
7
+
8
+ beforeAll(async () => {
9
+ fetchMock.enableMocks();
10
+ fetchMock.mockResponse('{}');
11
+
12
+ client = new StatsigClient('client-key', { userID: '' });
13
+ client.initializeSync();
14
+
15
+ client.getExperiment('');
16
+ });
17
+
18
+ it('calls /initialize', () => {
19
+ expect(fetchMock).toHaveBeenCalledTimes(1);
20
+ expect(fetchMock).toHaveBeenCalledWith(
21
+ expect.stringContaining(
22
+ 'https://api.statsig.com/v1/initialize?k=client-key&st=javascript-client',
23
+ ),
24
+ expect.any(Object),
25
+ );
26
+ });
27
+ });
@@ -0,0 +1,108 @@
1
+ import fetchMock from 'jest-fetch-mock';
2
+ import { CreateTestPromise, TestPromise } from 'statsig-test-helpers';
3
+
4
+ import StatsigClient from '../StatsigClient';
5
+ import { MockLocalStorage } from './MockLocalStorage';
6
+ import InitializeResponse from './initialize.json';
7
+
8
+ const FIRST_RESPONSE = () =>
9
+ new Response(getResponseWithConfigValue({ isFirst: true }), { headers: {} });
10
+
11
+ const SECOND_RESPONSE = () =>
12
+ new Response(getResponseWithConfigValue({ isSecond: true }), { headers: {} });
13
+
14
+ describe('Racing Updates', () => {
15
+ let firstReqPromise: TestPromise<Response>;
16
+ let secondReqPromise: TestPromise<Response>;
17
+ let client: StatsigClient;
18
+ let storageMock: MockLocalStorage;
19
+
20
+ afterAll(() => {
21
+ jest.clearAllMocks();
22
+ MockLocalStorage.disableMockStorage();
23
+ });
24
+
25
+ beforeAll(async () => {
26
+ storageMock = MockLocalStorage.enabledMockStorage();
27
+ storageMock.clear();
28
+
29
+ firstReqPromise = CreateTestPromise<Response>();
30
+ secondReqPromise = CreateTestPromise<Response>();
31
+
32
+ fetchMock.enableMocks();
33
+ fetchMock.mockResponseOnce('{}');
34
+
35
+ client = new StatsigClient('client-key', {
36
+ userID: 'initial',
37
+ });
38
+
39
+ await client.initializeAsync();
40
+
41
+ let times = 0;
42
+ fetchMock.mockImplementation(async (_url, _payload) => {
43
+ if (times++ === 0) {
44
+ return firstReqPromise;
45
+ }
46
+ return secondReqPromise;
47
+ });
48
+
49
+ client.updateUserAsync({ userID: 'first-update' }).catch((e) => {
50
+ throw e;
51
+ });
52
+
53
+ client.updateUserAsync({ userID: 'second-update' }).catch((e) => {
54
+ throw e;
55
+ });
56
+ });
57
+
58
+ describe('when the second request finishes first', () => {
59
+ beforeAll(async () => {
60
+ secondReqPromise.resolve(SECOND_RESPONSE());
61
+ firstReqPromise.resolve(FIRST_RESPONSE());
62
+
63
+ await new Promise((r) => setTimeout(r, 1)); // Next Loop
64
+ });
65
+
66
+ it('correctly returns the second value', () => {
67
+ const config = client.getDynamicConfig('a_dynamic_config');
68
+ expect(config.value).toEqual({ isSecond: true });
69
+ });
70
+
71
+ it('writes both values to cache', () => {
72
+ const keys = Object.keys(storageMock.data);
73
+ expect(keys).toContain('statsig.cached.evaluations.2153812029');
74
+ expect(keys).toContain('statsig.cached.evaluations.1807619807');
75
+ });
76
+ });
77
+
78
+ describe('when the first request finishes first', () => {
79
+ beforeAll(async () => {
80
+ firstReqPromise.resolve(FIRST_RESPONSE());
81
+ secondReqPromise.resolve(SECOND_RESPONSE());
82
+
83
+ await new Promise((r) => setTimeout(r, 1)); // Next Loop
84
+ });
85
+
86
+ it('correctly returns the second value', () => {
87
+ const config = client.getDynamicConfig('a_dynamic_config');
88
+ expect(config.value).toEqual({ isSecond: true });
89
+ });
90
+
91
+ it('writes both values to cache', () => {
92
+ const keys = Object.keys(storageMock.data);
93
+ expect(keys).toContain('statsig.cached.evaluations.2153812029');
94
+ expect(keys).toContain('statsig.cached.evaluations.1807619807');
95
+ });
96
+ });
97
+ });
98
+
99
+ function getResponseWithConfigValue(value: Record<string, unknown>): string {
100
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
101
+ const result = JSON.parse(JSON.stringify(InitializeResponse)) as any;
102
+
103
+ result['dynamic_configs']['3495537376' /* DJB2('a_dynamic_config') */][
104
+ 'value'
105
+ ] = value;
106
+
107
+ return JSON.stringify(result);
108
+ }
@@ -0,0 +1,30 @@
1
+ import fetchMock from 'jest-fetch-mock';
2
+
3
+ import { version } from '../../package.json';
4
+ import StatsigClient from '../StatsigClient';
5
+
6
+ describe('StatsigMetadata', () => {
7
+ let body: Record<string, unknown>;
8
+
9
+ beforeAll(async () => {
10
+ fetchMock.mockResponse('{}');
11
+
12
+ const client = new StatsigClient('', { userID: '' });
13
+ await client.initializeAsync();
14
+
15
+ const data = fetchMock.mock.calls?.[0]?.[1]?.body?.toString() ?? '{}';
16
+ body = JSON.parse(data) as Record<string, unknown>;
17
+ });
18
+
19
+ it('has the correct sdkType', () => {
20
+ expect(body['statsigMetadata']).toMatchObject({
21
+ sdkType: 'javascript-client',
22
+ });
23
+ });
24
+
25
+ it('has the correct sdkVersion', () => {
26
+ expect(body['statsigMetadata']).toMatchObject({
27
+ sdkVersion: version as string,
28
+ });
29
+ });
30
+ });
@@ -0,0 +1,117 @@
1
+ {
2
+ "__note": "Sample Response using the SDK Demo project",
3
+ "feature_gates": {
4
+ "610600137": {
5
+ "name": "610600137",
6
+ "value": false,
7
+ "rule_id": "59nkHdlmIytrqNG9iT7gkd:50.00:2",
8
+ "id_type": "userID",
9
+ "secondary_exposures": []
10
+ },
11
+ "2867927529": {
12
+ "name": "2867927529",
13
+ "value": true,
14
+ "rule_id": "2QWhVkWdUEXR6Q3KYgV73O",
15
+ "id_type": "userID",
16
+ "secondary_exposures": []
17
+ },
18
+ "3339045027": {
19
+ "name": "3339045027",
20
+ "value": false,
21
+ "rule_id": "default",
22
+ "id_type": "userID",
23
+ "secondary_exposures": []
24
+ }
25
+ },
26
+ "dynamic_configs": {
27
+ "782154425": {
28
+ "name": "782154425",
29
+ "value": {
30
+ "a_string": "test",
31
+ "a_bool": true
32
+ },
33
+ "rule_id": "3cOnAc0cD6eI8mYw6Ofpgb",
34
+ "group": "3cOnAc0cD6eI8mYw6Ofpgb",
35
+ "group_name": "Test",
36
+ "is_device_based": false,
37
+ "id_type": "userID",
38
+ "is_experiment_active": true,
39
+ "is_user_in_experiment": true,
40
+ "secondary_exposures": []
41
+ },
42
+ "2493531029": {
43
+ "name": "2493531029",
44
+ "value": {
45
+ "a_string": "c"
46
+ },
47
+ "rule_id": "1J6QLlETnyrV1XE2um2XiS",
48
+ "group": "1J6QLlETnyrV1XE2um2XiS",
49
+ "group_name": "Control",
50
+ "is_device_based": false,
51
+ "id_type": "userID",
52
+ "is_experiment_active": true,
53
+ "is_user_in_experiment": true,
54
+ "secondary_exposures": []
55
+ },
56
+ "3495537376": {
57
+ "name": "3495537376",
58
+ "value": {
59
+ "red": "#FF0000",
60
+ "blue": "#00FF00",
61
+ "green": "#0000FF"
62
+ },
63
+ "rule_id": "default",
64
+ "group": "default",
65
+ "is_device_based": false,
66
+ "id_type": "userID",
67
+ "secondary_exposures": []
68
+ },
69
+ "3921852239": {
70
+ "name": "3921852239",
71
+ "value": {
72
+ "a_string": "Experiment Test Value"
73
+ },
74
+ "rule_id": "49CGlTB7z97PEdqJapQplA",
75
+ "group": "49CGlTB7z97PEdqJapQplA",
76
+ "group_name": "Test",
77
+ "is_device_based": false,
78
+ "id_type": "userID",
79
+ "is_experiment_active": true,
80
+ "is_user_in_experiment": true,
81
+ "is_in_layer": true,
82
+ "explicit_parameters": ["a_string"],
83
+ "secondary_exposures": []
84
+ }
85
+ },
86
+ "layer_configs": {
87
+ "3011030003": {
88
+ "name": "3011030003",
89
+ "value": {
90
+ "a_string": "Experiment Test Value"
91
+ },
92
+ "rule_id": "49CGlTB7z97PEdqJapQplA",
93
+ "group": "49CGlTB7z97PEdqJapQplA",
94
+ "group_name": "Test",
95
+ "allocated_experiment_name": "3921852239",
96
+ "is_device_based": false,
97
+ "is_experiment_active": true,
98
+ "explicit_parameters": ["a_string"],
99
+ "is_user_in_experiment": true,
100
+ "secondary_exposures": [],
101
+ "undelegated_secondary_exposures": []
102
+ }
103
+ },
104
+ "sdkParams": {},
105
+ "has_updates": true,
106
+ "generator": "scrapi-nest",
107
+ "time": 1705543730484,
108
+ "company_lcut": 1705543730484,
109
+ "evaluated_keys": {},
110
+ "hash_used": "djb2",
111
+ "derived_fields": {
112
+ "user_agent": "PostmanRuntime/7.36.3",
113
+ "ip": "4.38.114.186",
114
+ "country": "US"
115
+ },
116
+ "hashed_sdk_key_used": "1295945792"
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import StatsigClient from './StatsigClient';
2
+ import { StatsigEvaluationsDataAdapter } from './StatsigEvaluationsDataAdapter';
3
+ import './StatsigMetadataAdditions';
4
+ import type { StatsigOptions } from './StatsigOptions';
5
+
6
+ export type {
7
+ StatsigEnvironment,
8
+ StatsigEvent,
9
+ StatsigUser,
10
+ } from '@statsig/client-core';
11
+
12
+ export { StatsigEvaluationsDataAdapter, StatsigClient, StatsigOptions };
13
+
14
+ export type { EvaluationResponse } from './EvaluationData';
15
+
16
+ __STATSIG__ = {
17
+ ...(__STATSIG__ ?? {}),
18
+ StatsigEvaluationsDataAdapter,
19
+ StatsigClient,
20
+ };
21
+
22
+ export default __STATSIG__;
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {},
4
+ "files": [],
5
+ "include": [],
6
+ "references": [
7
+ {
8
+ "path": "./tsconfig.lib.json"
9
+ },
10
+ {
11
+ "path": "./tsconfig.spec.json"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "declaration": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
10
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "types": ["jest", "node"],
7
+ "resolveJsonModule": true,
8
+ "esModuleInterop": true,
9
+ "sourceMap": true
10
+ },
11
+ "include": [
12
+ "jest.config.ts",
13
+ "src/**/*.test.ts",
14
+ "src/**/*.spec.ts",
15
+ "src/**/*.d.ts"
16
+ ]
17
+ }