@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,695 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const got = require('got')
4
+ const childProcess = require('child_process')
5
+ const LogBuffer = require('./logBuffer')
6
+ const { getSettingsFile } = require('./runtimeSettings')
7
+ const SampleBuffer = require('./resources/sampleBuffer')
8
+ const resourceSample = require('./resources/sample')
9
+
10
+ /** The point at which we check to see if we are in a boot loop */
11
+ const MAX_RESTART_COUNT = 5
12
+
13
+ /** The minimum run time we we expect when determining if we are in a boot loop
14
+ * - Run duration is measured from the time node-red is spawned to the time it crashes
15
+ */
16
+ const MIN_RUNTIME = 30000
17
+
18
+ /** If the distribution of run durations are less than this, we are likely in a boot loop
19
+ * - Run duration is measured from the time node-red is spawned to the time it crashes.
20
+ */
21
+ const MIN_RUNTIME_DEVIATION = 2000 // 2 seconds either side of the mean
22
+
23
+ /** How long wait for Node-RED to cleanly stop before killing */
24
+ const NODE_RED_STOP_TIMEOUT = 10000
25
+
26
+ /** Interval between status polls of Node-RED used to detect a hung runtime */
27
+ /** Interval between status polls of Node-RED used to detect a hung runtime */
28
+ const HEALTH_POLL_INTERVAL = 7499 // A prime number (to minimise syncing with other processes)
29
+
30
+ /** Timeout to apply to health polling requests */
31
+ const HEALTH_POLL_TIMEOUT = HEALTH_POLL_INTERVAL - 500 // Allow 500ms to avoid overlapping requests
32
+
33
+ /** The number of consecutive timeouts during startup phase before considering a NR hang */
34
+ const HEALTH_POLL_MAX_STARTUP_ERROR_COUNT = 10
35
+
36
+ /** The number of consecutive timeouts before considering a NR hang */
37
+ const HEALTH_POLL_MAX_ERROR_COUNT = 3
38
+
39
+ /** The number of seconds between resource polling */
40
+ const RESOURCE_POLL_INTERVAL = 10
41
+ const RESOURCE_ALERT_SAMPLES = 30
42
+ const RESOURCE_ALERT_THRESHOLD = 0.75
43
+ const CPU_LIMIT = process.env.FORGE_CPU_LIMIT
44
+ const MEMORY_LIMIT = process.env.FORGE_MEMORY_LIMIT
45
+
46
+ const States = {
47
+ STOPPED: 'stopped',
48
+ LOADING: 'loading',
49
+ INSTALLING: 'installing',
50
+ STARTING: 'starting',
51
+ RUNNING: 'running',
52
+ SAFE: 'safe',
53
+ CRASHED: 'crashed',
54
+ STOPPING: 'stopping'
55
+ }
56
+ /**
57
+ * options:
58
+ * - logBufferMax
59
+ * - forgeURL
60
+ * - project
61
+ * - token
62
+ * - execPath
63
+ */
64
+ class Launcher {
65
+ constructor (options) {
66
+ this.options = options
67
+ this.state = States.STOPPED
68
+ // Assume we want to start NR unless told otherwise via loadSettings
69
+ this.targetState = States.RUNNING
70
+ this.env = {
71
+ PATH: process.env.PATH
72
+ }
73
+ this.settings = null
74
+
75
+ // Array of times and run durations for monitoring boot loops
76
+ this.startTimes = []
77
+ this.runDurations = []
78
+
79
+ this.logBuffer = new LogBuffer(this.options.logBufferMax || 1000)
80
+ this.logBuffer.add({ level: 'system', msg: `Launcher version: ${this.options?.versions?.launcher || 'unknown'}` })
81
+
82
+ // A callback function that will be set if the launcher is waiting
83
+ // for Node-RED to exit
84
+ this.exitCallback = null
85
+
86
+ this.healthPoll = null
87
+ this.resourcePoll = null
88
+
89
+ // defaults to the last 3.5 hours of samples at 5 second intervals
90
+ this.sampleBuffer = new SampleBuffer(this.options.sampleBufferMax || 2520)
91
+ this.cpuAuditLogged = 0
92
+ this.memoryAuditLogged = 0
93
+ }
94
+
95
+ async loadSettings () {
96
+ this.state = States.LOADING
97
+ this.logBuffer.add({ level: 'system', msg: 'Loading project settings' })
98
+ const settingsURL = `${this.options.forgeURL}/api/v1/projects/${this.options.project}/settings`
99
+ let newSettings
100
+ try {
101
+ newSettings = await got(settingsURL, {
102
+ headers: {
103
+ authorization: `Bearer ${this.options.token}`
104
+ }
105
+ }).json()
106
+ } catch (err) {
107
+ this.settings = null
108
+ this.state = States.STOPPED
109
+ // if launcher failed to restart it will have old token
110
+ if (err?.response?.statusCode === 401) {
111
+ // throwing error will cause call to `start` to fail and an audit event will be logged
112
+ throw new Error('Unauthorized')
113
+ }
114
+ // what to do when forge is down?
115
+ console.log(`Unable to get settings from ${settingsURL} with error ${err.toString()}`)
116
+ return
117
+ }
118
+
119
+ this.settings = newSettings
120
+ this.settings.projectToken = this.options.token
121
+ this.settings.clientID = process.env.FORGE_CLIENT_ID
122
+ this.settings.teamID = process.env.FORGE_TEAM_ID
123
+ this.settings.clientSecret = process.env.FORGE_CLIENT_SECRET
124
+ this.settings.credentialSecret = process.env.FORGE_NR_SECRET
125
+ this.settings.allowInboundTcp = this.options?.allowInboundTcp
126
+ this.settings.allowInboundUdp = this.options?.allowInboundUdp
127
+ this.settings.licenseType = process.env.FORGE_LICENSE_TYPE
128
+ this.settings.broker = this.options.broker
129
+ this.settings.launcherVersion = this.options?.versions?.launcher || ''
130
+
131
+ // setup nodeDir to include the path to additional nodes and plugins
132
+ const nodesDir = []
133
+ if (Array.isArray(this.settings.nodesDir) && this.settings.nodesDir.length) {
134
+ nodesDir.push(...this.settings.nodesDir)
135
+ } else if (this.settings.nodesDir && typeof this.settings.nodesDir === 'string') {
136
+ nodesDir.push(this.settings.nodesDir)
137
+ }
138
+ nodesDir.push(path.join(require.main.path, 'node_modules', '@flowfuse', 'nr-project-nodes').replace(/\\/g, '/'))
139
+ nodesDir.push(path.join(require.main.path, '..', '..', '@flowfuse', 'nr-project-nodes').replace(/\\/g, '/'))
140
+ nodesDir.push(path.join(require.main.path, 'node_modules', '@flowfuse', 'nr-file-nodes').replace(/\\/g, '/'))
141
+ nodesDir.push(path.join(require.main.path, '..', '..', '@flowfuse', 'nr-file-nodes').replace(/\\/g, '/'))
142
+ nodesDir.push(require.main.path)
143
+
144
+ this.settings.nodesDir = nodesDir
145
+
146
+ const settingsFileContent = getSettingsFile(this.settings)
147
+ const settingsPath = path.join(this.settings.rootDir, this.settings.userDir, 'settings.js')
148
+ this.targetState = this.settings.state || States.RUNNING
149
+ try {
150
+ fs.writeFileSync(settingsPath, settingsFileContent)
151
+ } catch (error) {
152
+ this.state = States.STOPPED
153
+ this.logBuffer.add({ level: 'system', msg: 'Unable to write package file' })
154
+ throw error
155
+ }
156
+ const npmrcPath = path.join(this.settings.rootDir, this.settings.userDir, '.npmrc')
157
+ if (this.settings.settings.palette.npmrc) {
158
+ try {
159
+ fs.writeFileSync(npmrcPath, this.settings.settings.palette.npmrc)
160
+ } catch (error) {
161
+ this.state = States.STOPPED
162
+ this.logBuffer.add({ level: 'system', msg: 'Unable to write .npmrc file' })
163
+ throw error
164
+ }
165
+ } else {
166
+ if (fs.existsSync(npmrcPath)) {
167
+ try {
168
+ fs.unlinkSync(npmrcPath)
169
+ } catch (error) {
170
+ this.state = States.STOPPED
171
+ this.logBuffer.add({ level: 'system', msg: 'Unable to remove old .npmrc file' })
172
+ throw error
173
+ }
174
+ }
175
+ }
176
+
177
+ try {
178
+ await this.updatePackage()
179
+ } catch (error) {
180
+ this.state = States.STOPPED
181
+ this.logBuffer.add({ level: 'system', msg: 'Unable to install or update packages' })
182
+ throw error
183
+ }
184
+ this.logBuffer.add({ level: 'system', msg: `Target state is '${this.targetState}'` })
185
+ }
186
+
187
+ async updatePackage () {
188
+ const pkgFilePath = path.join(this.settings.rootDir, this.settings.userDir, 'package.json')
189
+ const packageContent = fs.readFileSync(pkgFilePath, { encoding: 'utf8' })
190
+ const pkg = JSON.parse(packageContent)
191
+ const existingDependencies = pkg.dependencies || {}
192
+ const wantedDependencies = this.settings.settings.palette?.modules || {}
193
+
194
+ const existingModules = Object.keys(existingDependencies)
195
+ const wantedModules = Object.keys(wantedDependencies)
196
+
197
+ let changed = false
198
+ if (existingModules.length !== wantedModules.length) {
199
+ changed = true
200
+ } else {
201
+ existingModules.sort()
202
+ wantedModules.sort()
203
+ for (let i = 0; i < existingModules.length; i++) {
204
+ if (existingModules[i] !== wantedModules[i]) {
205
+ changed = true
206
+ break
207
+ }
208
+ if (existingDependencies[existingModules[i]] !== wantedDependencies[wantedModules[i]]) {
209
+ changed = true
210
+ break
211
+ }
212
+ }
213
+ }
214
+
215
+ if (changed) {
216
+ this.state = States.INSTALLING
217
+ this.logBuffer.add({ level: 'system', msg: 'Updating project dependencies' })
218
+ pkg.dependencies = wantedDependencies
219
+ fs.writeFileSync(pkgFilePath, JSON.stringify(pkg, null, 2))
220
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
221
+ return new Promise((resolve, reject) => {
222
+ const child = childProcess.spawn(
223
+ npmCommand,
224
+ ['install', '--omit=dev', '--no-audit', '--no-update-notifier', '--no-fund'],
225
+ { windowsHide: true, cwd: path.join(this.settings.rootDir, this.settings.userDir) })
226
+ child.stdout.on('data', (data) => {
227
+ this.logBuffer.add({ level: 'system', msg: '[npm] ' + data })
228
+ })
229
+ child.stderr.on('data', (data) => {
230
+ this.logBuffer.add({ level: 'system', msg: '[npm] ' + data })
231
+ })
232
+ child.on('error', (err) => {
233
+ this.logBuffer.add({ level: 'system', msg: '[npm] ' + err.toString() })
234
+ })
235
+ child.on('close', (code) => {
236
+ if (code === 0) {
237
+ resolve()
238
+ } else {
239
+ reject(new Error('Failed to install project dependencies'))
240
+ }
241
+ })
242
+ }).catch(err => {
243
+ // Revert the package file to the previous content. That ensures
244
+ // it will try to install again the next time it attempts to run
245
+ fs.writeFileSync(pkgFilePath, packageContent)
246
+ throw err
247
+ })
248
+ }
249
+ }
250
+
251
+ async logAuditEvent (event, body) {
252
+ const data = {
253
+ timestamp: Date.now(),
254
+ event
255
+ }
256
+ if (body && typeof body === 'object') {
257
+ if (body.error) {
258
+ data.error = {
259
+ code: body.error.code || 'unexpected_error',
260
+ error: body.error.error || body.error.message || 'Unexpected error'
261
+ }
262
+ } else {
263
+ Object.assign(data, body)
264
+ }
265
+ }
266
+ return got.post(this.options.forgeURL + '/logging/' + this.options.project + '/audit', {
267
+ json: data,
268
+ headers: {
269
+ authorization: 'Bearer ' + this.options.token
270
+ }
271
+ }).catch(_err => {
272
+ console.error('Failed to log audit event', _err, event)
273
+ })
274
+ }
275
+
276
+ getState () {
277
+ return this.state
278
+ }
279
+
280
+ getLastStartTime () {
281
+ return this.startTimes.length !== 0 ? this.startTimes[this.startTimes.length - 1] : -1
282
+ }
283
+
284
+ getLog () {
285
+ return this.logBuffer
286
+ }
287
+
288
+ getResources () {
289
+ return this.sampleBuffer
290
+ }
291
+
292
+ isHealthy () {
293
+ return this.proc && this.proc.exitCode === null
294
+ }
295
+
296
+ async start (targetState) {
297
+ if (this.deferredStop) {
298
+ await this.deferredStop
299
+ }
300
+ if (targetState) {
301
+ this.targetState = targetState
302
+ }
303
+ if (!this.settings) {
304
+ throw new Error('Failed to load settings')
305
+ }
306
+ if (this.state === States.RUNNING || this.state === States.STARTING) {
307
+ // Already running or starting - no need to start again
308
+ return
309
+ }
310
+ if (this.targetState === States.STOPPED) {
311
+ // Target state is stopped - don't start
312
+ return
313
+ }
314
+ this.logBuffer.add({ level: 'system', msg: 'Starting Node-RED' })
315
+ const filterEnv = (env) =>
316
+ Object.entries(env).reduce((acc, [key, value]) =>
317
+ key.startsWith('FORGE') ? acc : { ...acc, [key]: value }, {})
318
+
319
+ // According to https://github.com/flowforge/flowforge-nr-launcher/pull/145,
320
+ // setting FORGE_EXPOSE_HOST_ENV on the container unlocks the host env propagation.
321
+ const appEnv = Object.assign({},
322
+ process.env.FORGE_EXPOSE_HOST_ENV ? filterEnv(process.env) : {},
323
+ this.env,
324
+ this.settings.env)
325
+ appEnv.TZ = this.settings.settings.timeZone
326
+
327
+ if (this.targetState === States.SAFE) {
328
+ appEnv.NODE_RED_ENABLE_SAFE_MODE = true
329
+ }
330
+
331
+ if (process.env.NODE_EXTRA_CA_CERTS) {
332
+ appEnv.NODE_EXTRA_CA_CERTS = process.env.NODE_EXTRA_CA_CERTS
333
+ }
334
+
335
+ if (process.env.HOME) {
336
+ appEnv.HOME = process.env.HOME
337
+ }
338
+
339
+ if (appEnv.NODE_RED_ENABLE_PROJECTS) {
340
+ delete appEnv.NODE_RED_ENABLE_PROJECTS
341
+ }
342
+
343
+ const nodePaths = [
344
+ path.join(require.main.path, 'node_modules'),
345
+ path.join(require.main.path, '..', '..')
346
+ ]
347
+
348
+ // Check to see if we're in the dev-env - in which case, we need to add the right
349
+ // path to NODE_PATH so that the settings file can load `@flowfuse/nr-launcher/*`
350
+ // We have to point at the driver-localfs node_modules as that will have the right paths
351
+ // in place to load this module.
352
+ const devEnvPath = path.join(require.main.path, '..', 'driver-localfs', 'node_modules')
353
+ if (fs.existsSync(devEnvPath)) {
354
+ nodePaths.push(devEnvPath)
355
+ }
356
+
357
+ appEnv.NODE_PATH = nodePaths.join(path.delimiter)
358
+ appEnv.LAUNCHER_VERSION = this.settings.launcherVersion
359
+
360
+ const processOptions = {
361
+ windowsHide: true,
362
+ env: appEnv,
363
+ stdio: ['ignore', 'pipe', 'pipe'],
364
+ cwd: path.join(this.settings.rootDir, this.settings.userDir)
365
+ }
366
+
367
+ const processArguments = [
368
+ '-u',
369
+ path.join(this.settings.rootDir, this.settings.userDir),
370
+ '-p',
371
+ this.settings.port
372
+ ]
373
+
374
+ if (this.settings.stack?.memory && /^[1-9]\d*$/.test(this.settings.stack.memory)) {
375
+ const memLimit = Math.round(this.settings.stack.memory * 0.75)
376
+ processArguments.push(`--max-old-space-size=${memLimit}`)
377
+ }
378
+ this.options.execPathJs = path.join(this.options.nodeRedPath, 'node_modules', 'node-red', 'red.js')
379
+ processArguments.unshift(this.options.execPathJs)
380
+ this.options.execPath = process.execPath
381
+ this.proc = childProcess.spawn(
382
+ this.options.execPath,
383
+ processArguments,
384
+ processOptions
385
+ )
386
+
387
+ this.state = States.STARTING
388
+
389
+ this.proc.on('spawn', () => {
390
+ // only works at NodeJS 16+
391
+ // this.proc.pid
392
+ this.startTimes.push(Date.now())
393
+ if (this.startTimes.length > MAX_RESTART_COUNT) {
394
+ this.startTimes.shift()
395
+ }
396
+ })
397
+
398
+ /**
399
+ * Performs an HTTP HEAD to the baseURL of Node-RED to check its liveness.
400
+ */
401
+ const statusPoll = async () => {
402
+ if (this.targetState === States.STOPPED) {
403
+ return States.STOPPED
404
+ }
405
+ const parsedUrl = new URL(this.settings.baseURL)
406
+ parsedUrl.protocol = 'http:'
407
+ parsedUrl.host = '127.0.0.1'
408
+ parsedUrl.port = this.settings.port
409
+ const pollUrl = parsedUrl.toString()
410
+
411
+ let statusCode = 0
412
+ try {
413
+ const opts = {
414
+ headers: {
415
+ pragma: 'no-cache',
416
+ 'Cache-Control': 'max-age=0, must-revalidate, no-cache'
417
+ },
418
+ timeout: { request: HEALTH_POLL_TIMEOUT },
419
+ retry: { limit: 0 }
420
+ }
421
+ // Use a HEAD request to minimise data transfer
422
+ const res = await got.head(pollUrl, opts)
423
+ statusCode = res.statusCode || 500
424
+ } catch (error) {
425
+ this.logBuffer.add({ level: 'system', msg: `Node-RED health check failed: ${error.toString()} (${pollUrl})` })
426
+ console.log('Failed to poll NR on ', pollUrl, error)
427
+ statusCode = error.response?.statusCode || 500
428
+ }
429
+ if (statusCode >= 200 && statusCode < 500) {
430
+ return States.RUNNING
431
+ }
432
+ throw new Error()
433
+ }
434
+
435
+ /**
436
+ * Called after Node-RED exits unexpectedly. It calculates the runtime
437
+ * duration and decides if we've detected a crash loop.
438
+ * If a loop is detected, restarts in safe mode.
439
+ * If a loop is detected and we're already in safe mode, stops Node-RED
440
+ * Otherwise, restarts normally
441
+ */
442
+ const restartAfterUnexpectedExit = async () => {
443
+ const lastStartTime = this.startTimes[this.startTimes.length - 1]
444
+ const duration = Math.abs(Date.now() - lastStartTime)
445
+ this.runDurations.push(duration)
446
+ if (this.runDurations.length > MAX_RESTART_COUNT) {
447
+ this.runDurations.shift()
448
+ }
449
+
450
+ this.logBuffer.add({ level: 'system', msg: `Node-RED unexpectedly stopped after: ${Math.round(duration / 1000)}s` })
451
+
452
+ // if start count == MAX_RESTART_COUNT, then check for boot loop
453
+ if (this.startTimes.length === MAX_RESTART_COUNT) {
454
+ // calculate the average runtime
455
+ const avg = this.runDurations.reduce((a, b) => a + b, 0) / this.runDurations.length
456
+
457
+ // calculate the mean deviation of runtime durations
458
+ const meanDeviation = this.runDurations.reduce((a, b) => a + Math.abs(b - avg), 0) / this.runDurations.length
459
+
460
+ // if the deviation is small (i.e. crashing at a consistent rate)
461
+ // or the average runtime is low, then it is likely we in a boot loop
462
+ if (meanDeviation < MIN_RUNTIME_DEVIATION || avg < MIN_RUNTIME) {
463
+ // go to safe mode (or stop if already in safe mode)
464
+ this.startTimes = []
465
+ this.runDurations = []
466
+ if (this.targetState === States.SAFE) {
467
+ this.logBuffer.add({ level: 'system', msg: 'Node-RED restart loop detected whilst in safe mode. Stopping.' })
468
+ this.targetState = States.STOPPED
469
+ } else {
470
+ this.logBuffer.add({ level: 'system', msg: 'Node-RED restart loop detected. Restarting in safe mode.' })
471
+ this.targetState = States.SAFE
472
+ this.start()
473
+ }
474
+ } else {
475
+ this.start()
476
+ }
477
+ } else {
478
+ this.start()
479
+ }
480
+ }
481
+
482
+ if (this.healthPoll) {
483
+ clearInterval(this.healthPoll)
484
+ }
485
+ let errorCount = 0
486
+ this.healthPoll = setInterval(() => {
487
+ if (this.state === States.STARTING || this.state === States.RUNNING) {
488
+ statusPoll().then(() => {
489
+ // A healthy result
490
+ errorCount = 0
491
+ if (this.state === States.STARTING) {
492
+ this.state = States.RUNNING
493
+ }
494
+ }).catch(async _ => {
495
+ // Error polling Node-RED settings page
496
+ errorCount++
497
+ if ((this.state === States.STARTING && errorCount === HEALTH_POLL_MAX_STARTUP_ERROR_COUNT) ||
498
+ (this.state === States.RUNNING && errorCount === HEALTH_POLL_MAX_ERROR_COUNT)) {
499
+ this.logBuffer.add({ level: 'system', msg: 'Node-RED hang detected.' })
500
+ const targetState = this.state
501
+
502
+ // Calling stop will clear the health poll interval
503
+ await this.stop()
504
+ this.targetState = targetState
505
+ // Restart via restartAfterUnexpectedExit as it will check
506
+ // for a restart loop
507
+ restartAfterUnexpectedExit()
508
+ }
509
+ })
510
+ }
511
+ }, HEALTH_POLL_INTERVAL)
512
+
513
+ if (this.resourcePoll) {
514
+ clearInterval(this.resourcePoll)
515
+ }
516
+
517
+ this.resourcePoll = setInterval(async () => {
518
+ const parsedUrl = new URL(this.settings.baseURL)
519
+ parsedUrl.protocol = 'http:'
520
+ parsedUrl.host = '127.0.0.1'
521
+ parsedUrl.port = this.settings.port
522
+ parsedUrl.pathname = '/ff/metrics'
523
+ const pollUrl = parsedUrl.toString()
524
+
525
+ const sample = await resourceSample(pollUrl, RESOURCE_POLL_INTERVAL)
526
+ this.sampleBuffer.add(sample)
527
+
528
+ // avg over the last minute for alerts
529
+ if (CPU_LIMIT || MEMORY_LIMIT) {
530
+ const avg = this.sampleBuffer.avgLastX(RESOURCE_ALERT_SAMPLES)
531
+ if (avg.count === RESOURCE_ALERT_SAMPLES) {
532
+ console.log(`${JSON.stringify(avg, null, 2)} ${avg.count}`)
533
+ if (CPU_LIMIT) {
534
+ if (avg.cpu > (CPU_LIMIT * 0.75)) {
535
+ if (this.cpuAuditLogged === 0) {
536
+ await this.logAuditEvent('resource.cpu', {
537
+ interval: (RESOURCE_POLL_INTERVAL * RESOURCE_ALERT_SAMPLES),
538
+ threshold: (RESOURCE_ALERT_THRESHOLD * 100)
539
+ })
540
+ this.cpuAuditLogged = RESOURCE_ALERT_SAMPLES
541
+ }
542
+ } else {
543
+ if (this.cpuAuditLogged > 0) {
544
+ this.cpuAuditLogged -= 1
545
+ }
546
+ }
547
+ }
548
+ if (MEMORY_LIMIT) {
549
+ if (avg.ps > (MEMORY_LIMIT * 0.75)) {
550
+ if (this.memoryAuditLogged === 0) {
551
+ await this.logAuditEvent('resource.memory', {
552
+ interval: (RESOURCE_POLL_INTERVAL * RESOURCE_ALERT_SAMPLES),
553
+ threshold: (RESOURCE_ALERT_THRESHOLD * 100)
554
+ })
555
+ this.memoryAuditLogged = RESOURCE_ALERT_SAMPLES
556
+ }
557
+ } else {
558
+ if (this.memoryAuditLogged > 0) {
559
+ this.memoryAuditLogged -= 1
560
+ }
561
+ }
562
+ }
563
+ }
564
+ }
565
+ }, RESOURCE_POLL_INTERVAL * 1000)
566
+
567
+ this.proc.on('close', (code, signal) => {
568
+ // console.log("node-red closed with", {code,signal})
569
+ })
570
+
571
+ this.proc.on('exit', async (code, signal) => {
572
+ this.logBuffer.add({ level: 'system', msg: `Node-RED exited rc=${code} signal=${signal}` })
573
+ // When childProcess.kill() is executed on windows, the exit code is null and the signal is 'SIGTERM'.
574
+ // So long as the process was instructed to STOP and its state is STOPPED, consider this a clean exit
575
+ if (process.platform === 'win32' && code === null && signal === 'SIGTERM' && this.targetState === States.STOPPED && this.state === States.STOPPED) {
576
+ code = 0
577
+ }
578
+ if (code === 0) {
579
+ this.state = States.STOPPED
580
+ await this.logAuditEvent('stopped')
581
+ } else {
582
+ this.state = States.CRASHED
583
+ await this.logAuditEvent('crashed')
584
+
585
+ // Only restart if our target state is not stopped
586
+ if (this.targetState !== States.STOPPED) {
587
+ restartAfterUnexpectedExit()
588
+ }
589
+ }
590
+ if (this.exitCallback) {
591
+ this.exitCallback()
592
+ }
593
+ })
594
+
595
+ this.proc.on('error', (err) => {
596
+ this.logBuffer.add({ level: 'system', msg: `Error with Node-RED process: ${err.toString()}` })
597
+ console.log('Process error: ' + err.toString())
598
+ })
599
+
600
+ let stdoutBuffer = ''
601
+ this.proc.stdout.on('data', (data) => {
602
+ // Do not assume `data` is a complete log record.
603
+ // Parse until newline
604
+ stdoutBuffer = stdoutBuffer + data
605
+ let linebreak = stdoutBuffer.indexOf('\n')
606
+ while (linebreak > -1) {
607
+ const line = stdoutBuffer.substring(0, linebreak)
608
+ if (line.length > 0) {
609
+ if (line[0] === '{' && line[line.length - 1] === '}') {
610
+ // In case something console.log's directly, we can't assume the line is JSON
611
+ // from our logger
612
+ try {
613
+ this.logBuffer.add(JSON.parse(line))
614
+ } catch (err) {
615
+ this.logBuffer.add({ msg: line })
616
+ }
617
+ } else {
618
+ this.logBuffer.add({ msg: line })
619
+ }
620
+ }
621
+ stdoutBuffer = stdoutBuffer.substring(linebreak + 1)
622
+ linebreak = stdoutBuffer.indexOf('\n')
623
+ }
624
+ })
625
+ }
626
+
627
+ async stop () {
628
+ this.logBuffer.add({ level: 'system', msg: 'Stopping Node-RED' })
629
+ // Stop the health poll - do not want to mistake a stopping/stopped Node-RED
630
+ // for an unhealthly one
631
+ clearInterval(this.healthPoll)
632
+ clearInterval(this.resourcePoll)
633
+ this.targetState = States.STOPPED
634
+ if (this.deferredStop) {
635
+ // A stop request is already inflight - return the existing deferred object
636
+ return this.deferredStop
637
+ }
638
+ if (this.proc) {
639
+ // Setup a promise that will resolve once the process has really exited
640
+ this.deferredStop = new Promise((resolve, reject) => {
641
+ // Setup a timeout so we can more forcefully kill Node-RED
642
+ // if it has hung
643
+ this.exitTimeout = setTimeout(() => {
644
+ this.logBuffer.add({ level: 'system', msg: 'Node-RED stop timed-out. Sending SIGKILL' })
645
+ if (this.proc) {
646
+ this.proc.kill('SIGKILL')
647
+ }
648
+ }, NODE_RED_STOP_TIMEOUT)
649
+ // Setup a callback for when the process has actually exited
650
+ this.exitCallback = () => {
651
+ clearTimeout(this.exitTimeout)
652
+ this.exitCallback = null
653
+ this.deferredStop = null
654
+ this.exitTimeout = null
655
+ this.proc.unref()
656
+ this.proc = undefined
657
+ resolve()
658
+ }
659
+ // Send a kill signal. On Linux this will be a SIGTERM and
660
+ // allow Node-RED to shutdown cleanly. Windows looks like it does
661
+ // it more forcefully by default.
662
+ this.proc.kill()
663
+ this.state = States.STOPPED
664
+ })
665
+ return this.deferredStop
666
+ } else {
667
+ this.state = States.STOPPED
668
+ }
669
+ }
670
+
671
+ async revokeUserToken (token) { // logout:nodered(step-5)
672
+ this.logBuffer.add({ level: 'system', msg: 'Node-RED logout requested' })
673
+ if (this.state !== States.RUNNING) {
674
+ // not running
675
+ return
676
+ }
677
+ try {
678
+ const adminAPI = `${this.settings.baseURL}/auth/revoke`
679
+ const json = { token, noRedirect: true }
680
+ const headers = {
681
+ authorization: 'Bearer ' + token,
682
+ 'cache-control': 'no-cache',
683
+ 'content-type': 'application/json',
684
+ 'node-red-api-version': 'v2',
685
+ pragma: 'no-cache',
686
+ Referer: this.settings.baseURL
687
+ }
688
+ await got.post(adminAPI, { json, headers })
689
+ } catch (error) {
690
+ this.logBuffer.add({ level: 'system', msg: `Error logging out Node-RED: ${error.toString()}` })
691
+ }
692
+ }
693
+ }
694
+
695
+ module.exports = { Launcher, States }