@flowfuse/nr-launcher 1.13.3

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/LICENSE +178 -0
  3. package/README.md +28 -0
  4. package/index.js +148 -0
  5. package/lib/admin.js +95 -0
  6. package/lib/auditLogger/index.js +41 -0
  7. package/lib/auth/adminAuth.js +77 -0
  8. package/lib/auth/httpAuthMiddleware.js +71 -0
  9. package/lib/auth/httpAuthPlugin.js +10 -0
  10. package/lib/auth/strategy.js +34 -0
  11. package/lib/context/FFContextStorage.js +422 -0
  12. package/lib/context/index.js +9 -0
  13. package/lib/context/memoryCache.js +156 -0
  14. package/lib/launcher.js +695 -0
  15. package/lib/logBuffer.js +57 -0
  16. package/lib/resources/resourcePlugin.js +20 -0
  17. package/lib/resources/sample.js +57 -0
  18. package/lib/resources/sampleBuffer.js +85 -0
  19. package/lib/runtimeSettings.js +320 -0
  20. package/lib/storage/index.js +92 -0
  21. package/lib/storage/libraryPlugin.js +90 -0
  22. package/lib/theme/LICENSE +178 -0
  23. package/lib/theme/README.md +24 -0
  24. package/lib/theme/common/forge-common.css +108 -0
  25. package/lib/theme/common/forge-common.js +75 -0
  26. package/lib/theme/forge-dark/forge-dark-custom.css +2 -0
  27. package/lib/theme/forge-dark/forge-dark-custom.js +1 -0
  28. package/lib/theme/forge-dark/forge-dark-monaco.json +213 -0
  29. package/lib/theme/forge-dark/forge-dark-theme.css +12 -0
  30. package/lib/theme/forge-dark/forge-dark.js +61 -0
  31. package/lib/theme/forge-light/forge-light-custom.css +2 -0
  32. package/lib/theme/forge-light/forge-light-custom.js +1 -0
  33. package/lib/theme/forge-light/forge-light-monaco.json +227 -0
  34. package/lib/theme/forge-light/forge-light-theme.css +12 -0
  35. package/lib/theme/forge-light/forge-light.js +62 -0
  36. package/package.json +72 -0
  37. package/resources/favicon-16x16.png +0 -0
  38. package/resources/favicon-32x32.png +0 -0
  39. package/resources/favicon.ico +0 -0
  40. package/resources/ff-nr.png +0 -0
@@ -0,0 +1,77 @@
1
+ const { OAuth2 } = require('oauth')
2
+ const { Strategy } = require('./strategy')
3
+
4
+ module.exports = (options) => {
5
+ ['clientID', 'clientSecret', 'forgeURL', 'baseURL'].forEach(prop => {
6
+ if (!options[prop]) {
7
+ throw new Error(`Missing configuration option ${prop}`)
8
+ }
9
+ })
10
+
11
+ const clientID = options.clientID
12
+ const clientSecret = options.clientSecret
13
+ const forgeURL = options.forgeURL
14
+ const baseURL = options.baseURL
15
+
16
+ const callbackURL = `${baseURL}/auth/strategy/callback`
17
+ const authorizationURL = `${forgeURL}/account/authorize`
18
+ const tokenURL = `${forgeURL}/account/token`
19
+ const userInfoURL = `${forgeURL}/api/v1/user`
20
+
21
+ const oa = new OAuth2(clientID, clientSecret, '', authorizationURL, tokenURL)
22
+
23
+ const version = require('../../package.json').version
24
+
25
+ const activeUsers = {}
26
+
27
+ function addUser (username, profile, refreshToken, expiresIn) {
28
+ if (activeUsers[username]) {
29
+ clearTimeout(activeUsers[username].refreshTimeout)
30
+ }
31
+ activeUsers[username] = {
32
+ profile,
33
+ refreshToken,
34
+ expiresIn
35
+ }
36
+ activeUsers[username].refreshTimeout = setTimeout(function () {
37
+ oa.getOAuthAccessToken(refreshToken, {
38
+ grant_type: 'refresh_token'
39
+ }, function (err, accessToken, refreshToken, results) {
40
+ if (err) {
41
+ delete activeUsers[username]
42
+ } else {
43
+ addUser(username, profile, refreshToken, results.expires_in)
44
+ }
45
+ })
46
+ }, expiresIn * 1000)
47
+ }
48
+
49
+ return {
50
+ type: 'strategy',
51
+ strategy: {
52
+ name: 'FlowFuse',
53
+ autoLogin: true,
54
+ label: 'Sign in',
55
+ strategy: Strategy,
56
+ options: {
57
+ authorizationURL,
58
+ tokenURL,
59
+ callbackURL,
60
+ userInfoURL,
61
+ scope: `editor-${version}`,
62
+ clientID,
63
+ clientSecret,
64
+ pkce: true,
65
+ state: true,
66
+ verify: function (accessToken, refreshToken, params, profile, done) {
67
+ profile.permissions = [params.scope || 'read']
68
+ addUser(profile.username, profile, refreshToken, params.expires_in)
69
+ done(null, profile)
70
+ }
71
+ }
72
+ },
73
+ users: async function (username) {
74
+ return activeUsers[username] && activeUsers[username].profile
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,71 @@
1
+ const { Passport } = require('passport')
2
+ const { Strategy } = require('./strategy')
3
+
4
+ let options
5
+ let passport
6
+
7
+ module.exports = {
8
+ init (_options) {
9
+ options = _options
10
+ return (req, res, next) => {
11
+ try {
12
+ if (req.session.ffSession) {
13
+ next()
14
+ } else {
15
+ req.session.redirectTo = req.originalUrl
16
+ passport.authenticate('FlowFuse', { session: false })(req, res, next)
17
+ }
18
+ } catch (err) {
19
+ console.log(err.stack)
20
+ throw err
21
+ }
22
+ }
23
+ },
24
+
25
+ setupAuthRoutes (app) {
26
+ if (!options) {
27
+ // If `init` has not been called, then the flowforge-user auth type
28
+ // has not been selected. No need to setup any further routes.
29
+ return
30
+ }
31
+ // 'app' is RED.httpNode - the express app that handles all http routes
32
+ // exposed by the flows.
33
+
34
+ passport = new Passport()
35
+ app.use(passport.initialize())
36
+
37
+ const callbackURL = `${options.baseURL}/_ffAuth/callback`
38
+ const authorizationURL = `${options.forgeURL}/account/authorize`
39
+ const tokenURL = `${options.forgeURL}/account/token`
40
+ const userInfoURL = `${options.forgeURL}/api/v1/user`
41
+ const version = require('../../package.json').version
42
+
43
+ passport.use('FlowFuse', new Strategy({
44
+ authorizationURL,
45
+ tokenURL,
46
+ callbackURL,
47
+ userInfoURL,
48
+ scope: `httpAuth-${version}`,
49
+ clientID: options.clientID,
50
+ clientSecret: options.clientSecret,
51
+ pkce: true,
52
+ state: true
53
+ }, function (accessToken, refreshToken, params, profile, done) {
54
+ done(null, profile)
55
+ }))
56
+
57
+ app.get('/_ffAuth/callback', passport.authenticate('FlowFuse', {
58
+ session: false
59
+ }), (req, res) => {
60
+ req.session.user = req.user
61
+ req.session.ffSession = true
62
+ if (req.session?.redirectTo) {
63
+ const redirectTo = req.session.redirectTo
64
+ delete req.session.redirectTo
65
+ res.redirect(redirectTo)
66
+ } else {
67
+ res.redirect('/')
68
+ }
69
+ })
70
+ }
71
+ }
@@ -0,0 +1,10 @@
1
+ const { setupAuthRoutes } = require('./httpAuthMiddleware')
2
+
3
+ module.exports = (RED) => {
4
+ RED.plugins.registerPlugin('ff-auth-plugin', {
5
+ onadd: () => {
6
+ RED.log.info('FlowFuse HTTP Authentication Plugin loaded')
7
+ setupAuthRoutes(RED.httpNode)
8
+ }
9
+ })
10
+ }
@@ -0,0 +1,34 @@
1
+ const util = require('util')
2
+ const OAuth2Strategy = require('passport-oauth2')
3
+
4
+ function Strategy (options, verify) {
5
+ this.options = options
6
+ this._base = Object.getPrototypeOf(Strategy.prototype)
7
+ this._base.constructor.call(this, this.options, verify)
8
+ this.name = 'FlowFuse'
9
+ }
10
+
11
+ util.inherits(Strategy, OAuth2Strategy)
12
+
13
+ Strategy.prototype.userProfile = function (accessToken, done) {
14
+ this._oauth2.useAuthorizationHeaderforGET(true)
15
+ this._oauth2.get(this.options.userInfoURL, accessToken, (err, body) => {
16
+ if (err) {
17
+ return done(err)
18
+ }
19
+ try {
20
+ const json = JSON.parse(body)
21
+ done(null, {
22
+ username: json.username,
23
+ email: json.email,
24
+ image: json.avatar,
25
+ name: json.name,
26
+ userId: json.id
27
+ })
28
+ } catch (e) {
29
+ done(e)
30
+ }
31
+ })
32
+ }
33
+
34
+ module.exports = { Strategy }
@@ -0,0 +1,422 @@
1
+ const MemoryCache = require('./memoryCache')
2
+ const got = require('got').default
3
+ const safeJSONStringify = require('json-stringify-safe')
4
+ const CONFIG_ERROR_MSG = 'Persistent context plugin cannot be used outside of FlowFuse EE environment'
5
+
6
+ /**
7
+ * @typedef {Object} FFContextStorageConfig - The configuration object for the FlowFuse Context Storage
8
+ * @property {string} projectID - The FlowFuse project ID
9
+ * @property {string} token - The FlowFuse project token
10
+ * @property {string} [url='http://127.0.0.1:3001'] - The FlowFuse File Store URL
11
+ * @property {number} [requestTimeout=3000] - The number of milliseconds to wait before timing out a request
12
+ * @property {number} [pageSize=20] - The number of context items/rows to fetch per page
13
+ * @property {number} [flushInterval=30] - The number of seconds to wait before flushing pending writes
14
+ * @property {boolean} [cache=true] - Whether to cache context items in memory (required for synchronous get/set)
15
+ */
16
+
17
+ class FFContextStorage {
18
+ constructor (/** @type {FFContextStorageConfig} */ config) {
19
+ // Ensure sane config
20
+ config = config || {}
21
+ config.pageSize = Math.max(1, config.pageSize || 20)
22
+ config.requestTimeout = Math.max(500, config.requestTimeout || 3000)
23
+ config.flushInterval = Math.max(0, config.flushInterval || 30) * 1000
24
+ config.cache = Object.hasOwn(config, 'cache') ? config.cache : true
25
+ config.projectID = config?.projectID || (process.env.FF_FS_TEST_CONFIG ? process.env.FLOWFORGE_PROJECT_ID : null)
26
+ config.token = config?.token || (process.env.FF_FS_TEST_CONFIG ? process.env.FLOWFORGE_PROJECT_TOKEN : null)
27
+ config.url = config?.url || 'http://127.0.0.1:3001'
28
+
29
+ // setup class vars
30
+ this.validSetup = false
31
+ /** @type {FFContextStorageConfig} */
32
+ this.config = config
33
+ if (config.cache) {
34
+ this.cache = MemoryCache({})
35
+ }
36
+ this.pendingWrites = {}
37
+ this.knownCircularRefs = {}
38
+ this.writePromise = Promise.resolve()
39
+ this._flushPendingWrites = function () { return Promise.resolve() } // place holder for later
40
+ this.validSetup = config.projectID && config.token && config.url?.startsWith('http')
41
+ if (!this.validSetup) {
42
+ console.warn(CONFIG_ERROR_MSG)
43
+ }
44
+
45
+ // create HTTP client instance for this project
46
+ /** @type {import('got').Got} */
47
+ this.client = got.extend({
48
+ prefixUrl: `${config.url}/v1/context/${config.projectID}`,
49
+ headers: {
50
+ 'user-agent': 'FlowFuse Node-RED File Nodes for Storage Server',
51
+ authorization: 'Bearer ' + config.token
52
+ },
53
+ timeout: {
54
+ request: config.requestTimeout
55
+ },
56
+ retry: {
57
+ limit: 0
58
+ }
59
+ })
60
+ }
61
+
62
+ async open () {
63
+ const self = this
64
+ if (!self.validSetup) {
65
+ return Promise.reject(new Error(CONFIG_ERROR_MSG))
66
+ }
67
+ if (!self.cache) {
68
+ self._flushPendingWrites = function () { return Promise.resolve() }
69
+ return Promise.resolve()
70
+ }
71
+ const opts = {
72
+ responseType: 'json'
73
+ }
74
+ self.nextActiveCursor = null
75
+ const limit = self.config.pageSize
76
+ let cursor = null
77
+ let result = await getNext(cursor, limit)
78
+ while (result) {
79
+ updateCache(result)
80
+ cursor = result.meta?.next_cursor
81
+ if (cursor) {
82
+ result = await getNext(cursor, limit)
83
+ } else {
84
+ result = null
85
+ }
86
+ }
87
+
88
+ async function getNext (cursor, limit) {
89
+ const path = paginateUrl('cache', cursor, limit)
90
+ const response = await self.client.get(path, opts)
91
+ return response?.body
92
+ }
93
+
94
+ function updateCache (result) {
95
+ const rows = result?.data || []
96
+ for (let index = 0; index < rows.length; index++) {
97
+ const row = rows[index]
98
+ if (typeof row !== 'object') { continue }
99
+ const scope = row.scope
100
+ if (!scope) { continue }
101
+ const values = row?.values
102
+ if (typeof values !== 'object') { continue }
103
+ Object.keys(values).forEach(function (key) {
104
+ self.cache.set(scope, key, values[key])
105
+ })
106
+ }
107
+ }
108
+ // update _flushPendingWrites to a real function
109
+ self._flushPendingWrites = function () {
110
+ const scopes = Object.keys(self.pendingWrites)
111
+ self.pendingWrites = {}
112
+ const promises = []
113
+ const newContext = self.cache._export()
114
+ scopes.forEach(function (scope) {
115
+ const context = newContext[scope] || {}
116
+ const stringifiedContext = stringify(context)
117
+ if (stringifiedContext.circular && !self.knownCircularRefs[scope]) {
118
+ console.warn('context.flowforge.error-circular', scope)
119
+ self.knownCircularRefs[scope] = true
120
+ } else {
121
+ delete self.knownCircularRefs[scope]
122
+ }
123
+ promises.push(self._writeCache(scope, stringifiedContext.json))
124
+ })
125
+ delete self._pendingWriteTimeout
126
+ return Promise.all(promises)
127
+ }
128
+ }
129
+
130
+ close () {
131
+ const self = this
132
+ if (this.cache && this._pendingWriteTimeout) {
133
+ clearTimeout(this._pendingWriteTimeout)
134
+ delete this._pendingWriteTimeout
135
+ this.config.flushInterval = 0
136
+ self.writePromise = self.writePromise.then(function () {
137
+ return self._flushPendingWrites().catch(function (err) {
138
+ console.error('Error flushing pending context writes:' + err.toString())
139
+ })
140
+ })
141
+ }
142
+ return this.writePromise
143
+ }
144
+
145
+ /**
146
+ * Get one or more values from the context store
147
+ * @param {'context'|'flow'|'global'} scope - The scope of the context to get keys for
148
+ * @param {string|Array<string>} key - The key to get the value for
149
+ * @param {Function} callback - The callback to call when the value has been retrieved
150
+ * @example
151
+ * // get a single value
152
+ * http://localhost:3001/v1/context/project-id/flow?key=hello
153
+ * @example
154
+ * // get multiple values
155
+ * http://localhost:3001/v1/context/project-id/global?key=hello&key=nested.object.property
156
+ */
157
+ get (scope, key, callback) {
158
+ if (!this.validSetup) {
159
+ return eeRejectOrCallback(!callback, callback)
160
+ }
161
+ if (this.cache) {
162
+ return this.cache.get(scope, key, callback)
163
+ } else if (typeof callback !== 'function') {
164
+ throw new Error('This context store must be called asynchronously')
165
+ } else {
166
+ const path = `${scope}`
167
+ const keys = Array.isArray(key) ? key : [key]
168
+ const opts = {
169
+ search: new URLSearchParams(keys.map(k => ['key', k])),
170
+ responseType: 'json'
171
+ }
172
+ this.client.get(path, opts).then(res => {
173
+ callback(null, ...reviver(keys, res.body))
174
+ }).catch(error => {
175
+ callback(normaliseError(error))
176
+ })
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Set one or more values in the context store
182
+ * @param {'context'|'flow'|'global'} scope - The scope of the context to set
183
+ * @param {string|Array<string>} key - The key(s) to set the value for
184
+ * @param {string|Array<string>} value - The value(s) to set for the given scope + key(s)
185
+ * @param {Function} callback - The callback to call when the value(s) have been set
186
+ * @example
187
+ * // set a single value
188
+ * http://localhost:3001/v1/context/project-id/flow
189
+ * // body
190
+ * [{ "key": "hello", "value": "world" }]
191
+ * @example
192
+ * // set multiple values
193
+ * http://localhost:3001/v1/context/project-id/flow
194
+ * // body
195
+ * [{ "key": "hello", "value": "world" }, { "key": "nested.object.property", "value": "value" }]
196
+ */
197
+ set (scope, key, value, callback) {
198
+ const self = this
199
+ if (!self.validSetup) {
200
+ return eeRejectOrCallback(!callback, callback)
201
+ }
202
+ if (self.cache) {
203
+ self.cache.set(scope, key, value, callback)
204
+ self.pendingWrites[scope] = true
205
+ if (self._pendingWriteTimeout) {
206
+ // there's a pending write which will handle this
207
+ } else {
208
+ self._pendingWriteTimeout = setTimeout(function () {
209
+ self.writePromise = self.writePromise.then(function () {
210
+ return self._flushPendingWrites().catch(function (err) {
211
+ console.error('Error flushing pending context writes:' + err.toString())
212
+ })
213
+ })
214
+ }, self.flushInterval)
215
+ }
216
+ } else if (typeof callback !== 'function') {
217
+ throw new Error('This context store must be called asynchronously')
218
+ } else {
219
+ const path = `${scope}`
220
+ const data = Array.isArray(key) ? key.map((k, i) => ({ key: k, value: value[i] })) : [{ key, value }]
221
+ const stringifiedContext = stringify(data)
222
+ const opts = {
223
+ responseType: 'json',
224
+ body: stringifiedContext.json,
225
+ headers: { 'Content-Type': 'application/json;charset=utf-8' }
226
+ }
227
+ self.client.post(path, opts).then(res => {
228
+ callback(null)
229
+ }).catch(error => {
230
+ callback(normaliseError(error))
231
+ })
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Get a list of keys for a given scope
237
+ * @param {'context'|'flow'|'global'} scope - The scope of the context to get keys for
238
+ * @param {Function} callback - The callback to call when the keys have been retrieved
239
+ * @example
240
+ * http://localhost:3001/v1/context/project-id/global/keys
241
+ */
242
+ keys (scope, callback) {
243
+ if (!this.validSetup) {
244
+ if (callback) {
245
+ callback(null, []) // quietly return empty key list
246
+ } else {
247
+ return Promise.resolve([]) // quietly return empty key list
248
+ }
249
+ }
250
+ if (this.cache) {
251
+ return this.cache.keys(scope, callback)
252
+ }
253
+ if (typeof callback !== 'function') {
254
+ throw new Error('This context store must be called asynchronously')
255
+ }
256
+
257
+ const path = `${scope}/keys`
258
+ const opts = {
259
+ responseType: 'json',
260
+ headers: {
261
+ 'Content-Type': 'application/json;charset=utf-8',
262
+ Accept: 'application/json'
263
+ }
264
+ }
265
+ this.client.get(path, opts).then(res => {
266
+ callback(null, res.body || [])
267
+ }).catch(error => {
268
+ callback(normaliseError(error))
269
+ })
270
+ }
271
+
272
+ /**
273
+ * Delete the context of the given node/flow/global
274
+ * @param {String} scope - the scope to delete
275
+ */
276
+ delete (scope) {
277
+ const self = this
278
+ if (!self.validSetup) {
279
+ return Promise.resolve() // quietly ignore
280
+ }
281
+ let cachePromise
282
+ if (self.cache) {
283
+ cachePromise = self.cache.delete(scope)
284
+ } else {
285
+ cachePromise = Promise.resolve()
286
+ }
287
+ delete self.pendingWrites[scope]
288
+ return cachePromise.then(function () {
289
+ return self.client.delete(scope)
290
+ })
291
+ }
292
+
293
+ /**
294
+ * Delete any contexts that are no longer in use
295
+ * @param {Array<string>} _activeNodes - a list of nodes still active
296
+ */
297
+ clean (_activeNodes) {
298
+ const self = this
299
+ if (!self.validSetup) {
300
+ return Promise.resolve() // quietly ignore
301
+ }
302
+ const activeNodes = _activeNodes || []
303
+ const opts = { json: activeNodes }
304
+ let cachePromise
305
+ if (self.cache) {
306
+ cachePromise = self.cache.clean(activeNodes)
307
+ } else {
308
+ cachePromise = Promise.resolve()
309
+ }
310
+ self.knownCircularRefs = {}
311
+ return cachePromise.then(function () {
312
+ return self.client.post('clean', opts)
313
+ }).then(() => {
314
+ // done
315
+ }).catch(error => {
316
+ error.code ||= 'unexpected_error'
317
+ // TODO: log error?
318
+ })
319
+ }
320
+
321
+ _export () {
322
+ // TODO: needed? I think not looking through @node-red/runtime/lib/nodes/context/index.js
323
+ return []
324
+ }
325
+
326
+ async _writeCache (scope, jsonData) {
327
+ const self = this
328
+ const path = `cache/${scope}`
329
+ const opts = {
330
+ responseType: 'json',
331
+ body: jsonData,
332
+ headers: { 'Content-Type': 'application/json;charset=utf-8' }
333
+ }
334
+ await self.client.post(path, opts)
335
+ }
336
+ }
337
+
338
+ // #region helpers
339
+ function stringify (value) {
340
+ let hasCircular
341
+ const result = safeJSONStringify(value, null, null, function (k, v) { hasCircular = true })
342
+ return { json: result, circular: hasCircular }
343
+ }
344
+
345
+ const reviver = (keys, data) => {
346
+ const result = keys.map(key => {
347
+ const el = data.find(e => e.key === key)
348
+ return el?.value
349
+ })
350
+ return result
351
+ }
352
+
353
+ function eeRejectOrCallback (reject, callback) {
354
+ if (callback) {
355
+ callback(new Error(CONFIG_ERROR_MSG))
356
+ } else if (reject) {
357
+ return Promise.reject(new Error(CONFIG_ERROR_MSG))
358
+ }
359
+ }
360
+
361
+ function normaliseError (err) {
362
+ let niceError = new Error('Unexpected error.')
363
+ let statusCode = null
364
+ let childErr = {}
365
+ niceError.code ||= 'unexpected_error'
366
+ if (typeof err === 'string') {
367
+ niceError = new Error(err)
368
+ } else if (err?._normalised) {
369
+ return err // already normalised
370
+ }
371
+ if (err?.response) {
372
+ statusCode = err.response.statusCode
373
+ if (err.response.body) {
374
+ try {
375
+ if (err.response.body && typeof err.response.body === 'object') {
376
+ childErr = err.response.body
377
+ } else {
378
+ childErr = { ...JSON.parse(err.response.body.toString()) }
379
+ }
380
+ } catch (_error) { /* do nothing */ }
381
+ if (!childErr || typeof childErr !== 'object') {
382
+ childErr = {}
383
+ }
384
+ Object.assign(niceError, childErr)
385
+ niceError.message = childErr.error || childErr.message || niceError.message
386
+ niceError.code = childErr.code || niceError.code
387
+ niceError.stack = childErr.stack || niceError.stack
388
+ }
389
+ }
390
+ if (statusCode === 413) {
391
+ niceError.message = 'Quota exceeded.'
392
+ if (childErr && childErr.limit) {
393
+ niceError.message += ` The current limit is ${childErr.limit} bytes.`
394
+ }
395
+ niceError.code = 'quota_exceeded'
396
+ }
397
+ niceError.stack = niceError.stack || err.stack
398
+ niceError.code = niceError.code || err.code
399
+ niceError._normalised = true // prevent double processing
400
+ return niceError
401
+ }
402
+
403
+ function paginateUrl (url, cursor, limit, query) {
404
+ const queryString = new URLSearchParams()
405
+ if (cursor) {
406
+ queryString.append('cursor', cursor)
407
+ }
408
+ if (limit) {
409
+ queryString.append('limit', limit)
410
+ }
411
+ if (query) {
412
+ queryString.append('query', query)
413
+ }
414
+ const qs = queryString.toString()
415
+ if (qs === '') {
416
+ return url
417
+ }
418
+ return `${url}?${qs}`
419
+ }
420
+ // #endregion helpers
421
+
422
+ exports.FFContextStorage = FFContextStorage
@@ -0,0 +1,9 @@
1
+ const { FFContextStorage } = require('./FFContextStorage')
2
+ /**
3
+ * Create a New FFContextStorage
4
+ * @param {FFContextStorageConfig} config Config options (see FFContextStorageConfig typedef)
5
+ * @returns {FFContextStorage} A new FFContextStorage instance
6
+ */
7
+ module.exports = function (config) {
8
+ return new FFContextStorage(config)
9
+ }