capacitor-launchdarkly 0.0.0-stage → 0.1.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/CapacitorLaunchdarkly.podspec +20 -0
- package/LICENSE +21 -0
- package/Package.swift +32 -0
- package/README.md +747 -2
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/Json.kt +67 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyClient.kt +250 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyPlugin.kt +148 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LdConfigFactory.kt +34 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StartOptions.kt +76 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StoredContext.kt +47 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +1174 -0
- package/dist/esm/definitions.d.ts +520 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +46 -0
- package/dist/esm/web.js +204 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +221 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +224 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/LaunchDarklyPlugin/ConfigFactory.swift +29 -0
- package/ios/Sources/LaunchDarklyPlugin/JSONValues.swift +68 -0
- package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyClient.swift +165 -0
- package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyPlugin.swift +169 -0
- package/ios/Sources/LaunchDarklyPlugin/PrivacyInfo.xcprivacy +23 -0
- package/ios/Sources/LaunchDarklyPlugin/StartOptions.swift +79 -0
- package/ios/Sources/LaunchDarklyPlugin/StoredContext.swift +43 -0
- package/ios/Tests/LaunchDarklyPluginTests/BillingSafetyTests.swift +53 -0
- package/ios/Tests/LaunchDarklyPluginTests/JSONValuesTests.swift +67 -0
- package/ios/Tests/LaunchDarklyPluginTests/LaunchDarklyPluginTests.swift +37 -0
- package/ios/Tests/LaunchDarklyPluginTests/PluginConfigurationTests.swift +52 -0
- package/ios/Tests/LaunchDarklyPluginTests/StartContextTests.swift +36 -0
- package/package.json +105 -4
package/README.md
CHANGED
|
@@ -1,3 +1,748 @@
|
|
|
1
|
-
#
|
|
1
|
+
# capacitor-launchdarkly
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[LaunchDarkly](https://launchdarkly.com) feature flags for Capacitor apps, with one API on iOS, Android and the web. On iOS and
|
|
4
|
+
Android it runs LaunchDarkly's native client SDKs and can start the client natively as the app launches, before the web view
|
|
5
|
+
runs any of your code; on the web it runs LaunchDarkly's browser SDK.
|
|
6
|
+
|
|
7
|
+
| | iOS | Android | Web |
|
|
8
|
+
| ------------------------- | -------------------------------------- | -------------------------------------------- | ----------------------------------------------- |
|
|
9
|
+
| SDK | LaunchDarkly iOS client SDK 11.8.0 | LaunchDarkly Android client SDK 5.18.0 | `@launchdarkly/js-client-sdk` 4.x (you install) |
|
|
10
|
+
| Key | mobile key (`mob-…`) | mobile key (`mob-…`) | client-side ID |
|
|
11
|
+
| Where the client starts | natively at launch, or `initialize()` | natively at launch, or `initialize()` | `initialize()` |
|
|
12
|
+
| Stored context | `UserDefaults` | `SharedPreferences` | `localStorage` |
|
|
13
|
+
| `connectionModeChange` | yes | yes | never fired |
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install capacitor-launchdarkly
|
|
19
|
+
npx cap sync
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Capacitor 8. Android 7.0 (API 24) or newer, iOS 15 or newer. On iOS the plugin is a Swift package; CocoaPods works too.
|
|
23
|
+
The native SDKs come with the plugin, pinned to the versions above. An Android app can move its SDK with
|
|
24
|
+
`launchDarklyAndroidVersion` in `android/variables.gradle`.
|
|
25
|
+
|
|
26
|
+
To use the plugin **on the web** as well, install LaunchDarkly's browser SDK next to it:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @launchdarkly/js-client-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
It is an optional peer dependency. The plugin imports it only when `initialize()` runs in a browser, so a native-only app never
|
|
33
|
+
loads it. Without it, `initialize()` on the web rejects with an error saying to install it.
|
|
34
|
+
|
|
35
|
+
## Native start-up
|
|
36
|
+
|
|
37
|
+
Put the mobile key in `capacitor.config` and the client starts as the app launches:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"plugins": {
|
|
42
|
+
"LaunchDarkly": {
|
|
43
|
+
"mobileKey": "mob-00000000-0000-0000-0000-000000000000",
|
|
44
|
+
"initialContext": { "kind": "user", "key": "anonymous_user", "anonymous": true },
|
|
45
|
+
"startWaitSeconds": 5
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Why start natively rather than from JavaScript:
|
|
52
|
+
|
|
53
|
+
- **The flags are there at launch.** The plugin's `load()` runs while Capacitor creates its bridge, before the web view loads
|
|
54
|
+
your app, so the first request to LaunchDarkly is on its way (or already answered) when your code first asks.
|
|
55
|
+
- **No anonymous flip.** Every `identify()` stores the context it switched to, and the next start-up begins with that stored
|
|
56
|
+
context instead of `initialContext`. A signed-in user stays signed in as far as the flags are concerned, from the first frame,
|
|
57
|
+
and the SDK serves that context's cached flags while it fetches fresh ones.
|
|
58
|
+
- **One client per process.** The client belongs to the process, not to the web view: a recreated activity or a reloaded page
|
|
59
|
+
finds it running. A later `initialize()` with the same key changes nothing and answers the current state.
|
|
60
|
+
|
|
61
|
+
The web layer then reads what is already there:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { LaunchDarkly } from 'capacitor-launchdarkly';
|
|
65
|
+
|
|
66
|
+
const { initialized, context, flags } = await LaunchDarkly.getState(); // waits for the start-up if it is still running
|
|
67
|
+
|
|
68
|
+
await LaunchDarkly.addListener('flagChange', ({ key, value, previous }) => {
|
|
69
|
+
console.log(`${key} changed from ${JSON.stringify(previous)} to ${JSON.stringify(value)}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const { value } = await LaunchDarkly.variation({ key: 'new-checkout', defaultValue: false });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
On sign-in and sign-out, switch the context. The plugin stores it, so the next launch starts with it:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
await LaunchDarkly.identify({ context: { kind: 'user', key: user.id, name: user.name } });
|
|
79
|
+
// signing out: back to the shared anonymous context, which becomes the stored one
|
|
80
|
+
await LaunchDarkly.identify({ context: { kind: 'user', key: 'anonymous_user', anonymous: true } });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`clearStoredContext()` forgets the stored context altogether, so the next start-up uses `initialContext` again.
|
|
84
|
+
|
|
85
|
+
Do not commit a real mobile key to a public repository. A mobile key is not a secret in the way a server-side SDK key is (it
|
|
86
|
+
ships inside every copy of your app), but it is still yours: keep it in your app's own configuration.
|
|
87
|
+
|
|
88
|
+
Without a `mobileKey` in the config, nothing starts natively, and the app calls `initialize()` with one, as it does on the web.
|
|
89
|
+
|
|
90
|
+
## Starting from JavaScript, and on the web
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { Capacitor } from '@capacitor/core';
|
|
94
|
+
|
|
95
|
+
await LaunchDarkly.initialize({
|
|
96
|
+
mobileKey: 'mob-…', // used on iOS and Android
|
|
97
|
+
clientSideId: '…', // used on the web
|
|
98
|
+
context: { kind: 'user', key: 'anonymous_user', anonymous: true },
|
|
99
|
+
options: { startWaitSeconds: 5, bootstrap: 'localStorage' },
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`initialize()` accepts both keys, and each platform takes its own. The context you pass plays the part of `initialContext`: a
|
|
104
|
+
context an earlier `identify()` stored wins over it, on the web too.
|
|
105
|
+
|
|
106
|
+
On the web, `options.bootstrap` decides what the client shows before LaunchDarkly answers: `'localStorage'` starts from the flags
|
|
107
|
+
the browser SDK cached for that context on an earlier visit (the SDK does this on its own), and a flag object, for example one
|
|
108
|
+
your server rendered into the page, starts from those values.
|
|
109
|
+
|
|
110
|
+
What has no direct equivalent in the browser SDK:
|
|
111
|
+
|
|
112
|
+
- **`setOnline()`** – the browser SDK has no offline mode. Offline closes the stream, so no flag updates arrive; online opens it
|
|
113
|
+
again when streaming is on. Analytics events are still sent.
|
|
114
|
+
- **`offline: true`** at start – starts without a stream; the first flags are still fetched once.
|
|
115
|
+
- **`setStreaming()`** – the browser SDK switches in place. The native SDKs cannot change their data source while they run, so
|
|
116
|
+
there the plugin closes the client and starts it again with the current context.
|
|
117
|
+
- **`connectionModeChange`** – never fired on the web.
|
|
118
|
+
|
|
119
|
+
## Contexts, and what LaunchDarkly bills
|
|
120
|
+
|
|
121
|
+
Contexts use the JSON shape of LaunchDarkly's SDKs: a single context `{ kind, key, name?, anonymous?, ...attributes }`, custom
|
|
122
|
+
attributes of any JSON type (nested objects and arrays included), private attributes in `_meta.privateAttributes`, and
|
|
123
|
+
multi-contexts `{ kind: 'multi', user: {...}, organization: {...} }`. The native side hands the JSON to the SDK's own parser
|
|
124
|
+
(`JsonSerialization` on Android, `LDContext`'s `Decodable` on iOS), so every field means what it means to LaunchDarkly.
|
|
125
|
+
|
|
126
|
+
LaunchDarkly bills client-side SDKs by monthly active users: the number of unique context keys, counted for the context kind that
|
|
127
|
+
has the most ([how billing is calculated](https://launchdarkly.com/docs/home/account/calculating-billing)). The plugin makes sure
|
|
128
|
+
that only the keys your app passes are ever counted:
|
|
129
|
+
|
|
130
|
+
- **Automatic environment attributes are off**, on both native platforms (`AutoEnvAttributes.Disabled` / `.disabled`), and the
|
|
131
|
+
browser SDK never adds them. With them on, the SDK adds `ld_application` and `ld_device` contexts to every evaluation, and the
|
|
132
|
+
`ld_device` key is generated per install, which counts every device as a context of its own
|
|
133
|
+
([automatic environment attributes](https://launchdarkly.com/docs/sdk/features/environment-attributes)). This is not an
|
|
134
|
+
option.
|
|
135
|
+
- **Keys are never generated.** An anonymous context keeps the key you give it, so an app can use one shared key (for example
|
|
136
|
+
`anonymous_user`) for every signed-out user. Android is configured with `generateAnonymousKeys(false)`; the iOS SDK generates a
|
|
137
|
+
key only for a context built without one, which the plugin never does; and a context without a key is refused on every
|
|
138
|
+
platform, because the browser SDK would otherwise generate one for an anonymous context.
|
|
139
|
+
|
|
140
|
+
Native unit tests pin both: the built configuration has environment attributes off and key generation off, and an anonymous
|
|
141
|
+
context comes out of the SDK's own context processing with its key (see "Testing").
|
|
142
|
+
|
|
143
|
+
`sendEventsOnlyForVariation`, an option of the old JavaScript SDK, has no equivalent in either native SDK. `getState()` and
|
|
144
|
+
`allFlags()` read the SDK's store and send no evaluation events on any platform; `variation()` sends one, as any SDK evaluation
|
|
145
|
+
does.
|
|
146
|
+
|
|
147
|
+
## iOS privacy manifest
|
|
148
|
+
|
|
149
|
+
The plugin keeps the stored context in `UserDefaults`, one of Apple's
|
|
150
|
+
[required-reason APIs](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api),
|
|
151
|
+
and ships its own `PrivacyInfo.xcprivacy` declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason `CA92.1`. The
|
|
152
|
+
LaunchDarkly SDK ships its own manifest too. Declare the same category in your app's manifest if your app does not already.
|
|
153
|
+
|
|
154
|
+
## Configuration
|
|
155
|
+
|
|
156
|
+
<docgen-config>
|
|
157
|
+
<!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
|
|
158
|
+
|
|
159
|
+
Starts the native LaunchDarkly client as the app launches, before the
|
|
160
|
+
web view runs any app code. Leave `mobileKey` out to start it from
|
|
161
|
+
JavaScript with `initialize()` instead. iOS and Android only.
|
|
162
|
+
|
|
163
|
+
| Prop | Type | Description | Default | Since |
|
|
164
|
+
| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----- |
|
|
165
|
+
| **`mobileKey`** | <code>string</code> | The mobile key of the LaunchDarkly environment. Without it there is no native start-up, and `initialize()` starts the client instead. | | 0.1.0 |
|
|
166
|
+
| **`initialContext`** | <code><a href="#ldcontext">LDContext</a></code> | The context the native start-up uses when no context was stored by an earlier `identify()`. | <code>{ kind: 'user', key: 'anonymous_user', anonymous: true }</code> | 0.1.0 |
|
|
167
|
+
| **`streaming`** | <code>boolean</code> | Streams flag updates when `true`, polls when `false`. | <code>true</code> | 0.1.0 |
|
|
168
|
+
| **`evaluationReasons`** | <code>boolean</code> | Asks LaunchDarkly for the reason behind each evaluation. | <code>false</code> | 0.1.0 |
|
|
169
|
+
| **`offline`** | <code>boolean</code> | Starts the client offline, until `setOnline({ online: true })`. | <code>false</code> | 0.1.0 |
|
|
170
|
+
| **`startWaitSeconds`** | <code>number</code> | How long the start waits for the first set of flags, in seconds. | <code>5</code> | 0.1.0 |
|
|
171
|
+
| **`application`** | <code><a href="#ldapplicationinfo">LDApplicationInfo</a></code> | Application metadata sent to LaunchDarkly with every request. | | 0.1.0 |
|
|
172
|
+
|
|
173
|
+
### Examples
|
|
174
|
+
|
|
175
|
+
In `capacitor.config.json`:
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"plugins": {
|
|
180
|
+
"LaunchDarkly": {
|
|
181
|
+
"mobileKey": "mob-00000000-0000-0000-0000-000000000000",
|
|
182
|
+
"initialContext": { "kind": "user", "key": "anonymous_user", "anonymous": true },
|
|
183
|
+
"streaming": true,
|
|
184
|
+
"evaluationReasons": false,
|
|
185
|
+
"offline": false,
|
|
186
|
+
"startWaitSeconds": 5,
|
|
187
|
+
"application": { "id": "com.example.plugin", "version": "1.0.0" }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
In `capacitor.config.ts`:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
/// <reference types="capacitor-launchdarkly" />
|
|
197
|
+
|
|
198
|
+
import { CapacitorConfig } from '@capacitor/cli';
|
|
199
|
+
|
|
200
|
+
const config: CapacitorConfig = {
|
|
201
|
+
plugins: {
|
|
202
|
+
LaunchDarkly: {
|
|
203
|
+
mobileKey: "mob-00000000-0000-0000-0000-000000000000",
|
|
204
|
+
initialContext: { "kind": "user", "key": "anonymous_user", "anonymous": true },
|
|
205
|
+
streaming: true,
|
|
206
|
+
evaluationReasons: false,
|
|
207
|
+
offline: false,
|
|
208
|
+
startWaitSeconds: 5,
|
|
209
|
+
application: { "id": "com.example.plugin", "version": "1.0.0" },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export default config;
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
</docgen-config>
|
|
218
|
+
|
|
219
|
+
## API
|
|
220
|
+
|
|
221
|
+
<docgen-index>
|
|
222
|
+
|
|
223
|
+
* [`initialize(...)`](#initialize)
|
|
224
|
+
* [`waitForInitialization(...)`](#waitforinitialization)
|
|
225
|
+
* [`getState()`](#getstate)
|
|
226
|
+
* [`identify(...)`](#identify)
|
|
227
|
+
* [`variation(...)`](#variation)
|
|
228
|
+
* [`allFlags()`](#allflags)
|
|
229
|
+
* [`setStreaming(...)`](#setstreaming)
|
|
230
|
+
* [`setOnline(...)`](#setonline)
|
|
231
|
+
* [`flush()`](#flush)
|
|
232
|
+
* [`close()`](#close)
|
|
233
|
+
* [`clearStoredContext()`](#clearstoredcontext)
|
|
234
|
+
* [`addListener('flagChange', ...)`](#addlistenerflagchange-)
|
|
235
|
+
* [`addListener('connectionModeChange', ...)`](#addlistenerconnectionmodechange-)
|
|
236
|
+
* [`removeAllListeners()`](#removealllisteners)
|
|
237
|
+
* [Interfaces](#interfaces)
|
|
238
|
+
* [Type Aliases](#type-aliases)
|
|
239
|
+
|
|
240
|
+
</docgen-index>
|
|
241
|
+
|
|
242
|
+
<docgen-api>
|
|
243
|
+
<!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
|
|
244
|
+
|
|
245
|
+
### initialize(...)
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
initialize(options: InitializeOptions) => Promise<LaunchDarklyState>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Starts the client from JavaScript and waits for the first set of flags or
|
|
252
|
+
`options.startWaitSeconds` (5 by default), whichever comes first.
|
|
253
|
+
|
|
254
|
+
- **iOS, Android**: takes `mobileKey`. Needed only by an app that does not
|
|
255
|
+
put a `mobileKey` in `capacitor.config`; with one there, the client is
|
|
256
|
+
already running when the app's code starts, and this call only answers
|
|
257
|
+
its state.
|
|
258
|
+
- **Web**: takes `clientSideId`, and is the only way to start the client.
|
|
259
|
+
Needs `@launchdarkly/js-client-sdk` installed (see the README).
|
|
260
|
+
|
|
261
|
+
`context` plays the part of `initialContext`: when an earlier `identify()`
|
|
262
|
+
stored a context, the client starts with the stored one instead, so the
|
|
263
|
+
app does not flip to an anonymous context for a moment after a restart.
|
|
264
|
+
|
|
265
|
+
There is one client per process (per page on the web). A second call with
|
|
266
|
+
the same key, or a call after the native start-up already started a
|
|
267
|
+
client with that key, changes nothing: the client is not created again,
|
|
268
|
+
`context` and `options` are ignored, and the current state is answered.
|
|
269
|
+
Use `identify()` to switch the context. A call with a different key closes
|
|
270
|
+
the running client and starts a new one.
|
|
271
|
+
|
|
272
|
+
| Param | Type |
|
|
273
|
+
| ------------- | --------------------------------------------------------------- |
|
|
274
|
+
| **`options`** | <code><a href="#initializeoptions">InitializeOptions</a></code> |
|
|
275
|
+
|
|
276
|
+
**Returns:** <code>Promise<<a href="#launchdarklystate">LaunchDarklyState</a>></code>
|
|
277
|
+
|
|
278
|
+
**Since:** 0.1.0
|
|
279
|
+
|
|
280
|
+
--------------------
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
### waitForInitialization(...)
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
waitForInitialization(options?: WaitForInitializationOptions | undefined) => Promise<WaitForInitializationResult>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Answers whether the client has received its first set of flags, waiting
|
|
290
|
+
up to `timeoutSeconds` (5 by default) for that when it has not yet.
|
|
291
|
+
|
|
292
|
+
Resolves `{ initialized: false }` straight away when no client was
|
|
293
|
+
started, neither natively nor with `initialize()`.
|
|
294
|
+
|
|
295
|
+
| Param | Type |
|
|
296
|
+
| ------------- | ------------------------------------------------------------------------------------- |
|
|
297
|
+
| **`options`** | <code><a href="#waitforinitializationoptions">WaitForInitializationOptions</a></code> |
|
|
298
|
+
|
|
299
|
+
**Returns:** <code>Promise<<a href="#waitforinitializationresult">WaitForInitializationResult</a>></code>
|
|
300
|
+
|
|
301
|
+
**Since:** 0.1.0
|
|
302
|
+
|
|
303
|
+
--------------------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
### getState()
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
getState() => Promise<LaunchDarklyState>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The state of the native client: whether it has its flags, the current
|
|
313
|
+
context and the value of every flag, read from the native SDK's own
|
|
314
|
+
store. Meant for bootstrapping the web layer at start without a second
|
|
315
|
+
network round trip.
|
|
316
|
+
|
|
317
|
+
While the native start-up is still running, this waits until it is done:
|
|
318
|
+
until the first set of flags is in, or `startWaitSeconds` has passed.
|
|
319
|
+
With no client at all it answers `initialized: false` and no flags.
|
|
320
|
+
|
|
321
|
+
Reading the flags this way sends no evaluation events.
|
|
322
|
+
|
|
323
|
+
**Returns:** <code>Promise<<a href="#launchdarklystate">LaunchDarklyState</a>></code>
|
|
324
|
+
|
|
325
|
+
**Since:** 0.1.0
|
|
326
|
+
|
|
327
|
+
--------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
### identify(...)
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
identify(options: IdentifyOptions) => Promise<IdentifyResult>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Switches the client to another context and resolves when the flags for
|
|
337
|
+
it are loaded, or when `timeoutSeconds` (5 by default) has passed.
|
|
338
|
+
|
|
339
|
+
The context the SDK accepted is stored natively, under a key of the
|
|
340
|
+
plugin's own, and the next native start-up begins with it instead of
|
|
341
|
+
`initialContext`. So an app logs out by identifying its anonymous
|
|
342
|
+
context, which then becomes the stored one; `clearStoredContext()`
|
|
343
|
+
forgets it altogether.
|
|
344
|
+
|
|
345
|
+
An anonymous context keeps the key it is given. The plugin never asks the
|
|
346
|
+
SDK to generate one.
|
|
347
|
+
|
|
348
|
+
| Param | Type |
|
|
349
|
+
| ------------- | ----------------------------------------------------------- |
|
|
350
|
+
| **`options`** | <code><a href="#identifyoptions">IdentifyOptions</a></code> |
|
|
351
|
+
|
|
352
|
+
**Returns:** <code>Promise<<a href="#identifyresult">IdentifyResult</a>></code>
|
|
353
|
+
|
|
354
|
+
**Since:** 0.1.0
|
|
355
|
+
|
|
356
|
+
--------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
### variation(...)
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
variation(options: VariationOptions) => Promise<VariationResult>
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Evaluates one flag for the current context and answers its value, of any
|
|
366
|
+
JSON type, or `defaultValue` (`null` when left out) when the flag is
|
|
367
|
+
unknown or the client has not started. Goes through the SDK's JSON
|
|
368
|
+
variation call, so it records an evaluation event like any SDK call.
|
|
369
|
+
|
|
370
|
+
| Param | Type |
|
|
371
|
+
| ------------- | ------------------------------------------------------------- |
|
|
372
|
+
| **`options`** | <code><a href="#variationoptions">VariationOptions</a></code> |
|
|
373
|
+
|
|
374
|
+
**Returns:** <code>Promise<<a href="#variationresult">VariationResult</a>></code>
|
|
375
|
+
|
|
376
|
+
**Since:** 0.1.0
|
|
377
|
+
|
|
378
|
+
--------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
### allFlags()
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
allFlags() => Promise<AllFlagsResult>
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The value of every flag for the current context, from the native SDK's
|
|
388
|
+
store. Sends no evaluation events. Empty when the client has not started.
|
|
389
|
+
|
|
390
|
+
**Returns:** <code>Promise<<a href="#allflagsresult">AllFlagsResult</a>></code>
|
|
391
|
+
|
|
392
|
+
**Since:** 0.1.0
|
|
393
|
+
|
|
394
|
+
--------------------
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
### setStreaming(...)
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
setStreaming(options: SetStreamingOptions) => Promise<void>
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Switches between streaming and polling. Neither native SDK can change its
|
|
404
|
+
data source while it runs, so the plugin closes the client and starts it
|
|
405
|
+
again with the same mobile key, options and current context, then waits as
|
|
406
|
+
at start-up. Flag listeners stay registered. The web SDK switches in place
|
|
407
|
+
(`setStreaming()`).
|
|
408
|
+
|
|
409
|
+
| Param | Type |
|
|
410
|
+
| ------------- | ------------------------------------------------------------------- |
|
|
411
|
+
| **`options`** | <code><a href="#setstreamingoptions">SetStreamingOptions</a></code> |
|
|
412
|
+
|
|
413
|
+
**Since:** 0.1.0
|
|
414
|
+
|
|
415
|
+
--------------------
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
### setOnline(...)
|
|
419
|
+
|
|
420
|
+
```typescript
|
|
421
|
+
setOnline(options: SetOnlineOptions) => Promise<void>
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Takes the client online or offline (`setOnline()` / `setOffline()` on
|
|
425
|
+
Android, `setOnline(_:)` on iOS). Offline, the client keeps answering the
|
|
426
|
+
values it has.
|
|
427
|
+
|
|
428
|
+
The web SDK has no offline mode. There, going offline closes the stream so
|
|
429
|
+
no flag updates arrive, and going online opens it again if `streaming` is
|
|
430
|
+
on; analytics events are still sent.
|
|
431
|
+
|
|
432
|
+
| Param | Type |
|
|
433
|
+
| ------------- | ------------------------------------------------------------- |
|
|
434
|
+
| **`options`** | <code><a href="#setonlineoptions">SetOnlineOptions</a></code> |
|
|
435
|
+
|
|
436
|
+
**Since:** 0.1.0
|
|
437
|
+
|
|
438
|
+
--------------------
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
### flush()
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
flush() => Promise<void>
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Sends the analytics events the client has queued.
|
|
448
|
+
|
|
449
|
+
**Since:** 0.1.0
|
|
450
|
+
|
|
451
|
+
--------------------
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
### close()
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
close() => Promise<void>
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Closes the client. It can be started again with `initialize()`; until
|
|
461
|
+
then, `variation()` answers the default value. The stored context is
|
|
462
|
+
kept.
|
|
463
|
+
|
|
464
|
+
**Since:** 0.1.0
|
|
465
|
+
|
|
466
|
+
--------------------
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
### clearStoredContext()
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
clearStoredContext() => Promise<void>
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Forgets the context `identify()` stored, so the next native start-up
|
|
476
|
+
begins with `initialContext` again. The running client is not changed.
|
|
477
|
+
|
|
478
|
+
**Since:** 0.1.0
|
|
479
|
+
|
|
480
|
+
--------------------
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
### addListener('flagChange', ...)
|
|
484
|
+
|
|
485
|
+
```typescript
|
|
486
|
+
addListener(eventName: 'flagChange', listenerFunc: (change: FlagChange) => void) => Promise<PluginListenerHandle>
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Called for every flag whose value changes: from the stream or a poll, and
|
|
490
|
+
after `identify()` for each flag whose value differs for the new context.
|
|
491
|
+
|
|
492
|
+
| Param | Type |
|
|
493
|
+
| ------------------ | ---------------------------------------------------------------------- |
|
|
494
|
+
| **`eventName`** | <code>'flagChange'</code> |
|
|
495
|
+
| **`listenerFunc`** | <code>(change: <a href="#flagchange">FlagChange</a>) => void</code> |
|
|
496
|
+
|
|
497
|
+
**Returns:** <code>Promise<<a href="#pluginlistenerhandle">PluginListenerHandle</a>></code>
|
|
498
|
+
|
|
499
|
+
**Since:** 0.1.0
|
|
500
|
+
|
|
501
|
+
--------------------
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
### addListener('connectionModeChange', ...)
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
addListener(eventName: 'connectionModeChange', listenerFunc: (change: ConnectionModeChange) => void) => Promise<PluginListenerHandle>
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Called when the SDK's connection mode changes, for example from streaming
|
|
511
|
+
to offline. iOS and Android only: the web SDK has no connection mode, and
|
|
512
|
+
this is never called there.
|
|
513
|
+
|
|
514
|
+
| Param | Type |
|
|
515
|
+
| ------------------ | ------------------------------------------------------------------------------------------ |
|
|
516
|
+
| **`eventName`** | <code>'connectionModeChange'</code> |
|
|
517
|
+
| **`listenerFunc`** | <code>(change: <a href="#connectionmodechange">ConnectionModeChange</a>) => void</code> |
|
|
518
|
+
|
|
519
|
+
**Returns:** <code>Promise<<a href="#pluginlistenerhandle">PluginListenerHandle</a>></code>
|
|
520
|
+
|
|
521
|
+
**Since:** 0.1.0
|
|
522
|
+
|
|
523
|
+
--------------------
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
### removeAllListeners()
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
removeAllListeners() => Promise<void>
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Removes every listener of this plugin.
|
|
533
|
+
|
|
534
|
+
**Since:** 0.1.0
|
|
535
|
+
|
|
536
|
+
--------------------
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
### Interfaces
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
#### LaunchDarklyState
|
|
543
|
+
|
|
544
|
+
| Prop | Type | Description | Since |
|
|
545
|
+
| ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----- |
|
|
546
|
+
| **`initialized`** | <code>boolean</code> | Whether the client has received its first set of flags, from the network or, when it was started offline, from its cache. | 0.1.0 |
|
|
547
|
+
| **`context`** | <code><a href="#ldcontext">LDContext</a></code> | The current context. Absent when no client was started. | 0.1.0 |
|
|
548
|
+
| **`flags`** | <code><a href="#ldflagset">LDFlagSet</a></code> | The value of every flag for the current context. | 0.1.0 |
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
#### LDSingleKindContext
|
|
552
|
+
|
|
553
|
+
A context of one kind. Any other property is a custom attribute: a string,
|
|
554
|
+
number, boolean, array or object.
|
|
555
|
+
|
|
556
|
+
| Prop | Type | Description | Since |
|
|
557
|
+
| --------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----- |
|
|
558
|
+
| **`kind`** | <code>string</code> | The context kind, for example `user` or `organization`. | 0.1.0 |
|
|
559
|
+
| **`key`** | <code>string</code> | The key. An anonymous context keeps the key it is given. | 0.1.0 |
|
|
560
|
+
| **`name`** | <code>string</code> | | 0.1.0 |
|
|
561
|
+
| **`anonymous`** | <code>boolean</code> | Leaves the context out of the dashboard's context list. It is still counted for billing by its key. | 0.1.0 |
|
|
562
|
+
| **`_meta`** | <code><a href="#ldcontextmeta">LDContextMeta</a></code> | | 0.1.0 |
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
#### LDContextMeta
|
|
566
|
+
|
|
567
|
+
| Prop | Type | Description | Since |
|
|
568
|
+
| ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------ | ----- |
|
|
569
|
+
| **`privateAttributes`** | <code>string[]</code> | Attributes, by name or `/`-separated reference, that are not sent to LaunchDarkly in analytics events. | 0.1.0 |
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
#### LDMultiKindContext
|
|
573
|
+
|
|
574
|
+
A context made of several kinds: `{ kind: 'multi', user: {...}, organization: {...} }`.
|
|
575
|
+
Each kind's object has no `kind` of its own.
|
|
576
|
+
|
|
577
|
+
| Prop | Type | Since |
|
|
578
|
+
| ---------- | -------------------- | ----- |
|
|
579
|
+
| **`kind`** | <code>'multi'</code> | 0.1.0 |
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
#### LDFlagSet
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
#### InitializeOptions
|
|
586
|
+
|
|
587
|
+
| Prop | Type | Description | Since |
|
|
588
|
+
| ------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------ | ----- |
|
|
589
|
+
| **`mobileKey`** | <code>string</code> | The mobile key of the LaunchDarkly environment, used on iOS and Android. | 0.1.0 |
|
|
590
|
+
| **`clientSideId`** | <code>string</code> | The client-side ID of the LaunchDarkly environment, used on the web. | 0.1.0 |
|
|
591
|
+
| **`context`** | <code><a href="#ldcontext">LDContext</a></code> | | 0.1.0 |
|
|
592
|
+
| **`options`** | <code><a href="#launchdarklyoptions">LaunchDarklyOptions</a></code> | | 0.1.0 |
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
#### LaunchDarklyOptions
|
|
596
|
+
|
|
597
|
+
| Prop | Type | Description | Default | Since |
|
|
598
|
+
| ----------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
|
|
599
|
+
| **`streaming`** | <code>boolean</code> | Streams flag updates when `true`, polls when `false`. | <code>true</code> | 0.1.0 |
|
|
600
|
+
| **`evaluationReasons`** | <code>boolean</code> | Asks LaunchDarkly for the reason behind each evaluation. The SDKs then attach it to their evaluation events. | <code>false</code> | 0.1.0 |
|
|
601
|
+
| **`offline`** | <code>boolean</code> | Starts the client offline: it answers cached or default values and does not connect until `setOnline({ online: true })`. The web SDK has no offline mode: there `offline: true` starts without a stream, and the first flags are still fetched once. | <code>false</code> | 0.1.0 |
|
|
602
|
+
| **`startWaitSeconds`** | <code>number</code> | How long the start waits for the first set of flags, in seconds. | <code>5</code> | 0.1.0 |
|
|
603
|
+
| **`application`** | <code><a href="#ldapplicationinfo">LDApplicationInfo</a></code> | Application metadata sent to LaunchDarkly with every request. It describes the app; it is not a context, and adds nothing to the contexts counted for billing. | | 0.1.0 |
|
|
604
|
+
| **`bootstrap`** | <code>'localStorage' \| <a href="#ldflagset">LDFlagSet</a></code> | Web only: flags to start with before LaunchDarkly answers. `'localStorage'` starts from the flags the web SDK cached in `localStorage` for the context on an earlier visit, which the SDK does on its own; a flag object (for example rendered by your server) starts from those values. The native SDKs always start from their own cache and ignore this. | | 0.1.0 |
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
#### LDApplicationInfo
|
|
608
|
+
|
|
609
|
+
| Prop | Type | Since |
|
|
610
|
+
| ----------------- | ------------------- | ----- |
|
|
611
|
+
| **`id`** | <code>string</code> | 0.1.0 |
|
|
612
|
+
| **`name`** | <code>string</code> | 0.1.0 |
|
|
613
|
+
| **`version`** | <code>string</code> | 0.1.0 |
|
|
614
|
+
| **`versionName`** | <code>string</code> | 0.1.0 |
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
#### WaitForInitializationResult
|
|
618
|
+
|
|
619
|
+
| Prop | Type | Since |
|
|
620
|
+
| ----------------- | -------------------- | ----- |
|
|
621
|
+
| **`initialized`** | <code>boolean</code> | 0.1.0 |
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
#### WaitForInitializationOptions
|
|
625
|
+
|
|
626
|
+
| Prop | Type | Default | Since |
|
|
627
|
+
| -------------------- | ------------------- | -------------- | ----- |
|
|
628
|
+
| **`timeoutSeconds`** | <code>number</code> | <code>5</code> | 0.1.0 |
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
#### IdentifyResult
|
|
632
|
+
|
|
633
|
+
| Prop | Type | Description | Since |
|
|
634
|
+
| -------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
|
635
|
+
| **`timedOut`** | <code>boolean</code> | `true` when the flags of the new context did not arrive in time. The context is switched all the same, and the flags follow when they arrive. | 0.1.0 |
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
#### IdentifyOptions
|
|
639
|
+
|
|
640
|
+
| Prop | Type | Description | Default | Since |
|
|
641
|
+
| -------------------- | ----------------------------------------------- | -------------------------------------------------------------- | -------------- | ----- |
|
|
642
|
+
| **`context`** | <code><a href="#ldcontext">LDContext</a></code> | | | 0.1.0 |
|
|
643
|
+
| **`timeoutSeconds`** | <code>number</code> | How long to wait for the flags of the new context, in seconds. | <code>5</code> | 0.1.0 |
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
#### VariationResult
|
|
647
|
+
|
|
648
|
+
| Prop | Type | Since |
|
|
649
|
+
| ----------- | -------------------- | ----- |
|
|
650
|
+
| **`value`** | <code>unknown</code> | 0.1.0 |
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
#### VariationOptions
|
|
654
|
+
|
|
655
|
+
| Prop | Type | Default | Since |
|
|
656
|
+
| ------------------ | -------------------- | ----------------- | ----- |
|
|
657
|
+
| **`key`** | <code>string</code> | | 0.1.0 |
|
|
658
|
+
| **`defaultValue`** | <code>unknown</code> | <code>null</code> | 0.1.0 |
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
#### AllFlagsResult
|
|
662
|
+
|
|
663
|
+
| Prop | Type | Since |
|
|
664
|
+
| ----------- | ----------------------------------------------- | ----- |
|
|
665
|
+
| **`flags`** | <code><a href="#ldflagset">LDFlagSet</a></code> | 0.1.0 |
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
#### SetStreamingOptions
|
|
669
|
+
|
|
670
|
+
| Prop | Type | Since |
|
|
671
|
+
| ------------- | -------------------- | ----- |
|
|
672
|
+
| **`enabled`** | <code>boolean</code> | 0.1.0 |
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
#### SetOnlineOptions
|
|
676
|
+
|
|
677
|
+
| Prop | Type | Since |
|
|
678
|
+
| ------------ | -------------------- | ----- |
|
|
679
|
+
| **`online`** | <code>boolean</code> | 0.1.0 |
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
#### PluginListenerHandle
|
|
683
|
+
|
|
684
|
+
| Prop | Type |
|
|
685
|
+
| ------------ | ----------------------------------------- |
|
|
686
|
+
| **`remove`** | <code>() => Promise<void></code> |
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
#### FlagChange
|
|
690
|
+
|
|
691
|
+
| Prop | Type | Description | Since |
|
|
692
|
+
| -------------- | -------------------- | ------------------------------------------------------ | ----- |
|
|
693
|
+
| **`key`** | <code>string</code> | | 0.1.0 |
|
|
694
|
+
| **`value`** | <code>unknown</code> | The new value; `null` when the flag was deleted. | 0.1.0 |
|
|
695
|
+
| **`previous`** | <code>unknown</code> | The value before the change, when the plugin knew one. | 0.1.0 |
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
#### ConnectionModeChange
|
|
699
|
+
|
|
700
|
+
| Prop | Type | Description | Since |
|
|
701
|
+
| ---------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
|
702
|
+
| **`mode`** | <code>string</code> | The SDK's own name for the mode, which differs between the platforms: on iOS `streaming`, `polling`, `offline` or `establishingStreamingConnection`; on Android the name of the SDK's `ConnectionMode`, such as `STREAMING`, `POLLING`, `OFFLINE`, `SET_OFFLINE` or `BACKGROUND_POLLING`. | 0.1.0 |
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
### Type Aliases
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
#### LDContext
|
|
709
|
+
|
|
710
|
+
A context in the JSON shape LaunchDarkly's SDKs use: a single-kind context
|
|
711
|
+
or a multi-context.
|
|
712
|
+
|
|
713
|
+
<code><a href="#ldsinglekindcontext">LDSingleKindContext</a> | <a href="#ldmultikindcontext">LDMultiKindContext</a></code>
|
|
714
|
+
|
|
715
|
+
</docgen-api>
|
|
716
|
+
|
|
717
|
+
## Testing
|
|
718
|
+
|
|
719
|
+
```bash
|
|
720
|
+
npm run test:unit # TypeScript: native calls reach the native plugin; the web implementation against a stand-in browser SDK
|
|
721
|
+
npm run test:parity # the API, the web, the Swift, the Kotlin and this README name the same methods and the same config keys
|
|
722
|
+
npm run test:android # JUnit on the JVM: config parsing, stored-context precedence, the SDK config, contexts and values
|
|
723
|
+
npm run test:ios # XCTest on a simulator: the same on iOS, and the privacy manifest
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
The billing rules are tested against the SDKs themselves, not only against the plugin's settings. On Android a test runs the
|
|
727
|
+
SDK's own `AnonymousKeyContextModifier`, the step `LDClient.init` and `identify` apply to every context, with the plugin's
|
|
728
|
+
configuration, and checks that `anonymous_user` comes out unchanged, with a control that turns generation on and sees the key
|
|
729
|
+
replaced. On iOS the tests decode contexts with the SDK's decoder and check the key and the `anonymous` attribute of single and
|
|
730
|
+
multi-contexts. On both, the auto-start path is tested to build a configuration with environment attributes off.
|
|
731
|
+
|
|
732
|
+
What the tests do not reach is LaunchDarkly itself: start the example app with a real mobile key to see flags arrive.
|
|
733
|
+
|
|
734
|
+
## Example app
|
|
735
|
+
|
|
736
|
+
`example-app/` installs the plugin from this repository and has a button for every method. It commits no key: type the mobile key
|
|
737
|
+
(or, in a browser, the client-side ID) into the field, or set `LAUNCHDARKLY_MOBILE_KEY` when you sync to try the native
|
|
738
|
+
start-up:
|
|
739
|
+
|
|
740
|
+
```bash
|
|
741
|
+
cd example-app
|
|
742
|
+
npm install
|
|
743
|
+
LAUNCHDARKLY_MOBILE_KEY=mob-… npx cap sync
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
## License
|
|
747
|
+
|
|
748
|
+
MIT
|