@platformatic/runtime 2.0.0-alpha.1 → 2.0.0-alpha.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 (170) hide show
  1. package/config.d.ts +285 -0
  2. package/eslint.config.js +8 -0
  3. package/fixtures/botched-start/platformatic.runtime.json +1 -1
  4. package/fixtures/botched-start/services/a/platformatic.service.json +1 -1
  5. package/fixtures/composerApp/platformatic.composer.json +1 -1
  6. package/fixtures/configs/invalid-autoload-with-services.json +1 -1
  7. package/fixtures/configs/invalid-entrypoint.json +1 -1
  8. package/fixtures/configs/invalid-schema-type.config.json +1 -1
  9. package/fixtures/configs/missing-property.config.json +1 -1
  10. package/fixtures/configs/missing-service-config.json +1 -1
  11. package/fixtures/configs/monorepo-composer-no-autoload.json +2 -2
  12. package/fixtures/configs/monorepo-composer.json +2 -2
  13. package/fixtures/configs/monorepo-create-cycle.json +2 -2
  14. package/fixtures/configs/monorepo-missing-dependencies.json +2 -2
  15. package/fixtures/configs/monorepo-no-cycles.json +2 -2
  16. package/fixtures/configs/monorepo-openapi.json +2 -2
  17. package/fixtures/configs/{monorepo-hotreload-env.json → monorepo-watch-env.json} +2 -2
  18. package/fixtures/configs/monorepo-watch-single.json +12 -0
  19. package/fixtures/configs/monorepo-watch.json +26 -9
  20. package/fixtures/configs/monorepo-with-dependencies.json +2 -2
  21. package/fixtures/configs/monorepo-with-management-api-without-metrics.json +21 -0
  22. package/fixtures/configs/monorepo-with-management-api.json +2 -2
  23. package/fixtures/configs/{monorepo-hotreload.json → monorepo-with-metrics.json} +5 -4
  24. package/fixtures/configs/monorepo.json +2 -2
  25. package/fixtures/configs/no-services.config.json +1 -1
  26. package/fixtures/configs/no-sources.config.json +1 -1
  27. package/fixtures/configs/service-throws-on-start.json +1 -1
  28. package/fixtures/configs/service-with-env-port.json +2 -2
  29. package/fixtures/configs/service-with-stdio.json +12 -0
  30. package/fixtures/configs/{hotreload.json → watch.json} +2 -2
  31. package/fixtures/dbApp/platformatic.db.json +1 -1
  32. package/fixtures/dbAppNoName/platformatic.db.json +1 -1
  33. package/fixtures/dbAppNoPackageJson/platformatic.db.json +1 -1
  34. package/fixtures/dbAppWithMigrationError/platformatic.db.json +1 -1
  35. package/fixtures/do-not-reload-dependencies/platformatic.service.json +1 -1
  36. package/fixtures/do-not-restart-on-crash/platformatic.runtime.json +3 -2
  37. package/fixtures/do-not-restart-on-crash/services/a/platformatic.service.json +1 -1
  38. package/fixtures/express/platformatic.runtime.json +1 -1
  39. package/fixtures/express/services/a/platformatic.service.json +1 -1
  40. package/fixtures/express/services/b/platformatic.service.json +1 -1
  41. package/fixtures/external-client/platformatic.service.json +1 -1
  42. package/fixtures/interceptors/idp.js +2 -2
  43. package/fixtures/interceptors/platformatic.runtime.json +1 -1
  44. package/fixtures/interceptors/services/a/platformatic.service.json +1 -1
  45. package/fixtures/interceptors-2/platformatic.runtime.json +1 -1
  46. package/fixtures/interceptors-2/services/a/platformatic.service.json +1 -1
  47. package/fixtures/leven/platformatic.runtime.json +2 -2
  48. package/fixtures/leven/services/deeply-spittle/platformatic.service.json +1 -1
  49. package/fixtures/leven/services/rainy-empire/platformatic.composer.json +1 -1
  50. package/fixtures/management-api/platformatic.json +3 -3
  51. package/fixtures/management-api/services/service-1/platformatic.json +1 -1
  52. package/fixtures/management-api/services/service-1/plugin.js +4 -3
  53. package/fixtures/management-api/services/service-2/platformatic.json +1 -1
  54. package/fixtures/management-api/services/service-db/platformatic.db.json +1 -1
  55. package/fixtures/management-api-custom-labels/platformatic.json +2 -2
  56. package/fixtures/management-api-custom-labels/services/service-1/platformatic.json +1 -1
  57. package/fixtures/management-api-custom-labels/services/service-1/plugin.js +4 -3
  58. package/fixtures/management-api-custom-labels/services/service-2/platformatic.json +1 -1
  59. package/fixtures/management-api-custom-labels/services/service-db/platformatic.db.json +1 -1
  60. package/fixtures/management-api-without-metrics/platformatic.json +3 -2
  61. package/fixtures/management-api-without-metrics/services/service-1/platformatic.json +1 -1
  62. package/fixtures/monorepo/composerApp/platformatic.composer.json +1 -1
  63. package/fixtures/monorepo/dbApp/platformatic.db.json +1 -1
  64. package/fixtures/monorepo/serviceApp/platformatic.service.json +3 -2
  65. package/fixtures/monorepo/serviceApp/with-logger/with-logger.cjs +2 -2
  66. package/fixtures/monorepo/serviceApp/with-logger/with-logger.d.ts +7 -7
  67. package/fixtures/monorepo/serviceAppWithLogger/platformatic.service.json +1 -1
  68. package/fixtures/monorepo/serviceAppWithLogger/plugin.js +12 -0
  69. package/fixtures/monorepo/serviceAppWithMultiplePlugins/platformatic.service.json +3 -2
  70. package/fixtures/monorepo-missing-dependencies/composer/platformatic.json +1 -1
  71. package/fixtures/monorepo-openapi/serviceAppWithoutOpenapi/platformatic.service.json +1 -1
  72. package/fixtures/monorepo-watch/service1/platformatic.service.json +1 -1
  73. package/fixtures/monorepo-with-dependencies/main/platformatic.json +1 -1
  74. package/fixtures/monorepo-with-dependencies/service-1/platformatic.json +1 -1
  75. package/fixtures/monorepo-with-dependencies/service-2/platformatic.json +1 -1
  76. package/fixtures/no-env.service.json +1 -1
  77. package/fixtures/preload/platformatic.runtime.json +1 -1
  78. package/fixtures/preload/services/a/platformatic.service.json +1 -1
  79. package/fixtures/prom-server/platformatic.json +2 -2
  80. package/fixtures/prom-server/services/service-1/platformatic.json +1 -1
  81. package/fixtures/prom-server/services/service-2/platformatic.json +1 -1
  82. package/fixtures/restart-on-crash/platformatic.runtime.json +1 -1
  83. package/fixtures/restart-on-crash/services/a/platformatic.service.json +1 -1
  84. package/fixtures/sample-runtime/package.json +1 -1
  85. package/fixtures/sample-runtime/platformatic.json +2 -2
  86. package/fixtures/sample-runtime/services/rival/package.json +1 -1
  87. package/fixtures/sample-runtime/services/rival/platformatic.json +1 -1
  88. package/fixtures/sample-runtime-with-2-services/package.json +1 -1
  89. package/fixtures/sample-runtime-with-2-services/platformatic.json +2 -2
  90. package/fixtures/sample-runtime-with-2-services/services/foobar/package.json +1 -1
  91. package/fixtures/sample-runtime-with-2-services/services/foobar/platformatic.json +1 -1
  92. package/fixtures/sample-runtime-with-2-services/services/rival/package.json +1 -1
  93. package/fixtures/sample-runtime-with-2-services/services/rival/platformatic.json +1 -1
  94. package/fixtures/server/logger-transport/platformatic.runtime.json +2 -2
  95. package/fixtures/server/logger-transport/services/echo/platformatic.service.json +1 -1
  96. package/fixtures/server/overrides-service/platformatic.runtime.json +2 -2
  97. package/fixtures/server/overrides-service/services/echo/platformatic.service.json +1 -1
  98. package/fixtures/server/runtime-server/platformatic.runtime.json +2 -2
  99. package/fixtures/server/runtime-server/services/echo/platformatic.service.json +1 -1
  100. package/fixtures/serviceAppThrowsOnStart/platformatic.service.json +1 -1
  101. package/fixtures/stackables/node_modules/foo/foo.js +2 -1
  102. package/fixtures/start-command-in-runtime.js +1 -1
  103. package/fixtures/stdio/platformatic.service.json +6 -0
  104. package/fixtures/stdio/plugin.js +24 -0
  105. package/fixtures/telemetry/platformatic.runtime.json +2 -2
  106. package/fixtures/telemetry/services/echo/platformatic.service.json +1 -1
  107. package/fixtures/typescript/platformatic.runtime.json +2 -2
  108. package/fixtures/typescript/services/composer/platformatic.composer.json +1 -1
  109. package/fixtures/typescript/services/movies/global.d.ts +2 -3
  110. package/fixtures/typescript/services/movies/platformatic.db.json +1 -1
  111. package/fixtures/typescript/services/movies/types/Movie.d.ts +3 -3
  112. package/fixtures/typescript/services/movies/types/index.d.ts +6 -6
  113. package/fixtures/typescript/services/titles/client/client.d.ts +35 -35
  114. package/fixtures/typescript/services/titles/platformatic.service.json +1 -1
  115. package/fixtures/typescript-custom-flags/platformatic.runtime.json +2 -2
  116. package/fixtures/typescript-custom-flags/services/composer/platformatic.composer.json +1 -1
  117. package/fixtures/typescript-custom-flags/services/movies/global.d.ts +2 -3
  118. package/fixtures/typescript-custom-flags/services/movies/platformatic.db.json +1 -1
  119. package/fixtures/typescript-custom-flags/services/movies/types/Movie.d.ts +3 -3
  120. package/fixtures/typescript-custom-flags/services/movies/types/index.d.ts +6 -6
  121. package/fixtures/typescript-custom-flags/services/titles/client/client.d.ts +35 -35
  122. package/fixtures/typescript-custom-flags/services/titles/platformatic.service.json +1 -1
  123. package/fixtures/typescript-no-env/platformatic.runtime.json +2 -2
  124. package/fixtures/typescript-no-env/services/composer/platformatic.composer.json +1 -1
  125. package/fixtures/typescript-no-env/services/movies/global.d.ts +2 -3
  126. package/fixtures/typescript-no-env/services/movies/platformatic.db.json +1 -1
  127. package/fixtures/typescript-no-env/services/movies/types/Movie.d.ts +3 -3
  128. package/fixtures/typescript-no-env/services/movies/types/index.d.ts +6 -6
  129. package/fixtures/typescript-no-env/services/titles/client/client.d.ts +35 -35
  130. package/fixtures/typescript-no-env/services/titles/platformatic.service.json +1 -1
  131. package/index.d.ts +7 -8
  132. package/index.js +14 -10
  133. package/index.test-d.ts +10 -12
  134. package/lib/build-server.js +5 -11
  135. package/lib/compile.js +11 -10
  136. package/lib/config.js +21 -14
  137. package/lib/dependencies.js +2 -1
  138. package/lib/errors.js +3 -2
  139. package/lib/generator/errors.js +1 -1
  140. package/lib/generator/runtime-generator.d.ts +15 -15
  141. package/lib/generator/runtime-generator.js +92 -63
  142. package/lib/logger.js +55 -0
  143. package/lib/management-api.js +29 -44
  144. package/lib/prom-server.js +5 -9
  145. package/lib/runtime.js +885 -0
  146. package/lib/schema.js +79 -76
  147. package/lib/start.js +35 -113
  148. package/lib/streams/message-port-writable.js +44 -0
  149. package/lib/streams/pino-writable.js +30 -0
  150. package/lib/upgrade.js +4 -3
  151. package/lib/utils.js +49 -1
  152. package/lib/versions/v1.36.0.js +1 -1
  153. package/lib/versions/v1.5.0.js +1 -1
  154. package/lib/versions/v2.0.0.js +17 -0
  155. package/lib/worker/app.js +224 -0
  156. package/lib/worker/default-stackable.js +27 -0
  157. package/lib/worker/itc.js +128 -0
  158. package/lib/worker/main.js +120 -0
  159. package/lib/worker/symbols.js +7 -0
  160. package/package.json +23 -25
  161. package/runtime.mjs +4 -4
  162. package/schema.json +824 -0
  163. package/lib/api-client.js +0 -500
  164. package/lib/api.js +0 -420
  165. package/lib/app.js +0 -397
  166. package/lib/load-config.js +0 -12
  167. package/lib/loader.mjs +0 -103
  168. package/lib/message-port-writable.js +0 -50
  169. package/lib/worker.js +0 -182
  170. /package/lib/{interceptors.js → worker/interceptors.js} +0 -0
package/lib/runtime.js ADDED
@@ -0,0 +1,885 @@
1
+ 'use strict'
2
+
3
+ const { once, EventEmitter } = require('node:events')
4
+ const { createReadStream, watch } = require('node:fs')
5
+ const { readdir, readFile, stat, access } = require('node:fs/promises')
6
+ const inspector = require('node:inspector')
7
+ const { join } = require('node:path')
8
+ const { setTimeout: sleep } = require('node:timers/promises')
9
+ const { Worker } = require('node:worker_threads')
10
+
11
+ const { ITC } = require('@platformatic/itc')
12
+ const ts = require('tail-file-stream')
13
+ const { createThreadInterceptor } = require('undici-thread-interceptor')
14
+
15
+ const { checkDependencies, topologicalSort } = require('./dependencies')
16
+ const errors = require('./errors')
17
+ const { createLogger } = require('./logger')
18
+ const { startManagementApi } = require('./management-api')
19
+ const { startPrometheusServer } = require('./prom-server')
20
+ const { getRuntimeTmpDir } = require('./utils')
21
+ const { sendViaITC } = require('./worker/itc')
22
+ const { kId, kITC, kConfig } = require('./worker/symbols')
23
+
24
+ const platformaticVersion = require('../package.json').version
25
+ const kWorkerFile = join(__dirname, 'worker/main.js')
26
+
27
+ const MAX_LISTENERS_COUNT = 100
28
+ const MAX_METRICS_QUEUE_LENGTH = 5 * 60 // 5 minutes in seconds
29
+ const COLLECT_METRICS_TIMEOUT = 1000
30
+
31
+ class Runtime extends EventEmitter {
32
+ #configManager
33
+ #runtimeTmpDir
34
+ #runtimeLogsDir
35
+ #env
36
+ #services
37
+ #servicesIds
38
+ #entrypoint
39
+ #entrypointId
40
+ #url
41
+ #loggerDestination
42
+ #metrics
43
+ #metricsTimeout
44
+ #status
45
+ #interceptor
46
+ #managementApi
47
+ #prometheusServer
48
+ #startedServices
49
+ #crashedServicesTimers
50
+
51
+ constructor (configManager, runtimeLogsDir, env) {
52
+ super()
53
+ this.setMaxListeners(MAX_LISTENERS_COUNT)
54
+
55
+ this.#configManager = configManager
56
+ this.#runtimeTmpDir = getRuntimeTmpDir(configManager.dirname)
57
+ this.#runtimeLogsDir = runtimeLogsDir
58
+ this.#env = env
59
+ this.#services = new Map()
60
+ this.#servicesIds = []
61
+ this.#url = undefined
62
+ // Note: nothing hits the main thread so there is no reason to set the globalDispatcher here
63
+ this.#interceptor = createThreadInterceptor({ domain: '.plt.local' })
64
+ this.#status = undefined
65
+ this.#startedServices = new Map()
66
+ this.#crashedServicesTimers = new Map()
67
+ }
68
+
69
+ async init () {
70
+ const config = this.#configManager.current
71
+ const autoloadEnabled = config.autoload
72
+
73
+ // This cannot be transferred to worker threads
74
+ delete config.configManager
75
+
76
+ if (config.managementApi) {
77
+ this.#managementApi = await startManagementApi(this, this.#configManager)
78
+ }
79
+
80
+ if (config.metrics) {
81
+ this.#prometheusServer = await startPrometheusServer(this, config.metrics)
82
+ }
83
+
84
+ // Create the logger
85
+ const [logger, destination] = createLogger(config, this.#runtimeLogsDir)
86
+ this.logger = logger
87
+ this.#loggerDestination = destination
88
+
89
+ // Handle inspector
90
+ const inspectorOptions = config.inspectorOptions
91
+ if (inspectorOptions) {
92
+ /* c8 ignore next 6 */
93
+ if (inspectorOptions.watchDisabled) {
94
+ logger.info('debugging flags were detected. hot reloading has been disabled')
95
+ }
96
+
97
+ inspector.open(inspectorOptions.port, inspectorOptions.host, inspectorOptions.breakFirstLine)
98
+ }
99
+
100
+ // Create all services, each in is own worker thread
101
+ for (const serviceConfig of config.services) {
102
+ // Setup forwarding of logs from the worker threads to the main thread
103
+ await this.#setupService(serviceConfig)
104
+ }
105
+
106
+ try {
107
+ // Make sure the list exists before computing the dependencies, otherwise some services might not be stopped
108
+ this.#servicesIds = config.services.map(service => service.id)
109
+
110
+ if (autoloadEnabled) {
111
+ checkDependencies(config.services)
112
+ this.#services = topologicalSort(this.#services, config)
113
+ }
114
+
115
+ // Recompute the list of services after sorting
116
+ this.#servicesIds = config.services.map(service => service.id)
117
+ } catch (e) {
118
+ await this.close()
119
+ throw e
120
+ }
121
+
122
+ this.#updateStatus('init')
123
+ }
124
+
125
+ async start () {
126
+ this.#updateStatus('starting')
127
+
128
+ // Important: do not use Promise.all here since it won't properly manage dependencies
129
+ for (const service of this.#servicesIds) {
130
+ await this.startService(service)
131
+ }
132
+
133
+ this.#updateStatus('started')
134
+
135
+ if (this.#managementApi && typeof this.#metrics === 'undefined') {
136
+ this.startCollectingMetrics()
137
+ }
138
+
139
+ return this.#url
140
+ }
141
+
142
+ async stop () {
143
+ if (this.#status === 'starting') {
144
+ await once(this, 'started')
145
+ }
146
+
147
+ this.#updateStatus('stopping')
148
+
149
+ for (const timer of this.#crashedServicesTimers.values()) {
150
+ clearTimeout(timer)
151
+ }
152
+
153
+ this.#startedServices.clear()
154
+ this.#crashedServicesTimers.clear()
155
+
156
+ await Promise.all(this.#servicesIds.map(service => this._stopService(service)))
157
+
158
+ this.#updateStatus('stopped')
159
+ }
160
+
161
+ async restart () {
162
+ this.emit('restarting')
163
+
164
+ await this.stop()
165
+ await this.start()
166
+
167
+ this.emit('restarted')
168
+
169
+ return this.#url
170
+ }
171
+
172
+ async close (fromManagementApi) {
173
+ this.#updateStatus('closing')
174
+
175
+ clearInterval(this.#metricsTimeout)
176
+
177
+ await this.stop()
178
+
179
+ if (this.#managementApi) {
180
+ if (fromManagementApi) {
181
+ // This allow a close request coming from the management API to correctly be handled
182
+ setImmediate(() => {
183
+ this.#managementApi.close()
184
+ })
185
+ } else {
186
+ await this.#managementApi.close()
187
+ }
188
+ }
189
+
190
+ if (this.#prometheusServer) {
191
+ await this.#prometheusServer.close()
192
+ }
193
+
194
+ if (this.logger) {
195
+ this.#loggerDestination.end()
196
+
197
+ this.logger = null
198
+ this.#loggerDestination = null
199
+ }
200
+
201
+ this.#updateStatus('closed')
202
+ }
203
+
204
+ async startService (id) {
205
+ if (this.#startedServices.get(id)) {
206
+ throw new errors.ApplicationAlreadyStartedError()
207
+ }
208
+
209
+ // This is set here so that if the service fails while starting we track the status
210
+ this.#startedServices.set(id, true)
211
+
212
+ // Make sure we don't restart twice
213
+ const crashedTimer = this.#crashedServicesTimers.get(id)
214
+ if (crashedTimer) {
215
+ clearTimeout(crashedTimer)
216
+ }
217
+
218
+ let service = await this.#getServiceById(id, false, false)
219
+
220
+ // The service was stopped, recreate the thread
221
+ if (!service) {
222
+ const config = this.#configManager.current
223
+ const serviceConfig = config.services.find(s => s.id === id)
224
+
225
+ await this.#setupService(serviceConfig)
226
+ service = await this.#getServiceById(id)
227
+ }
228
+
229
+ const serviceUrl = await sendViaITC(service, 'start')
230
+
231
+ if (serviceUrl) {
232
+ this.#url = serviceUrl
233
+ }
234
+ }
235
+
236
+ // Do not rename to #stopService as this is used in tests
237
+ async _stopService (id) {
238
+ const service = await this.#getServiceById(id, false, false)
239
+
240
+ if (!service) {
241
+ return
242
+ }
243
+
244
+ this.#startedServices.set(id, false)
245
+
246
+ // Always send the stop message, it will shut down workers that only had ITC and interceptors setup
247
+ try {
248
+ await Promise.race([sendViaITC(service, 'stop'), sleep(10000, 'timeout', { ref: false })])
249
+ } catch (error) {
250
+ this.logger.info(`Failed to stop service "${id}". Killing a worker thread.`, error)
251
+ }
252
+
253
+ // Wait for the worker thread to finish, we're going to create a new one if the service is ever restarted
254
+ const res = await Promise.race([once(service, 'exit'), sleep(10000, 'timeout', { ref: false })])
255
+
256
+ // If the worker didn't exit in time, kill it
257
+ if (res === 'timeout') {
258
+ await service.terminate()
259
+ }
260
+ }
261
+
262
+ async inject (id, injectParams) {
263
+ const service = await this.#getServiceById(id, true)
264
+ return sendViaITC(service, 'inject', injectParams)
265
+ }
266
+
267
+ startCollectingMetrics () {
268
+ this.#metrics = []
269
+ this.#metricsTimeout = setInterval(async () => {
270
+ if (this.#status !== 'started') {
271
+ return
272
+ }
273
+
274
+ let metrics = null
275
+ try {
276
+ metrics = await this.getFormattedMetrics()
277
+ } catch (error) {
278
+ if (!(error instanceof errors.RuntimeExitedError)) {
279
+ // TODO(mcollina): use the logger
280
+ console.error('Error collecting metrics', error)
281
+ }
282
+ return
283
+ }
284
+
285
+ this.emit('metrics', metrics)
286
+ this.#metrics.push(metrics)
287
+ if (this.#metrics.length > MAX_METRICS_QUEUE_LENGTH) {
288
+ this.#metrics.shift()
289
+ }
290
+ }, COLLECT_METRICS_TIMEOUT).unref()
291
+ }
292
+
293
+ async pipeLogsStream (writableStream, logger, startLogId, endLogId, runtimePID) {
294
+ endLogId = endLogId || Infinity
295
+ runtimePID = runtimePID ?? process.pid
296
+
297
+ const runtimeLogFiles = await this.#getRuntimeLogFiles(runtimePID)
298
+ if (runtimeLogFiles.length === 0) {
299
+ writableStream.end()
300
+ return
301
+ }
302
+
303
+ let latestFileId = parseInt(runtimeLogFiles.at(-1).slice('logs.'.length))
304
+
305
+ let fileStream = null
306
+ let fileId = startLogId ?? latestFileId
307
+ let isClosed = false
308
+
309
+ const runtimeLogsDir = this.#getRuntimeLogsDir(runtimePID)
310
+
311
+ const watcher = watch(runtimeLogsDir, async (event, filename) => {
312
+ if (event === 'rename' && filename.startsWith('logs')) {
313
+ const logFileId = parseInt(filename.slice('logs.'.length))
314
+ if (logFileId > latestFileId) {
315
+ latestFileId = logFileId
316
+ fileStream.unwatch()
317
+ }
318
+ }
319
+ }).unref()
320
+
321
+ const streamLogFile = () => {
322
+ if (fileId > endLogId) {
323
+ writableStream.end()
324
+ return
325
+ }
326
+
327
+ const fileName = 'logs.' + fileId
328
+ const filePath = join(runtimeLogsDir, fileName)
329
+
330
+ const prevFileStream = fileStream
331
+
332
+ fileStream = ts.createReadStream(filePath)
333
+ fileStream.pipe(writableStream, { end: false, persistent: false })
334
+
335
+ if (prevFileStream) {
336
+ prevFileStream.unpipe(writableStream)
337
+ prevFileStream.destroy()
338
+ }
339
+
340
+ fileStream.on('close', () => {
341
+ if (latestFileId > fileId && !isClosed) {
342
+ streamLogFile(++fileId)
343
+ }
344
+ })
345
+
346
+ fileStream.on('error', err => {
347
+ isClosed = true
348
+ logger.error(err, 'Error streaming log file')
349
+ fileStream.destroy()
350
+ watcher.close()
351
+ writableStream.end()
352
+ })
353
+
354
+ fileStream.on('eof', () => {
355
+ if (fileId >= endLogId) {
356
+ writableStream.end()
357
+ return
358
+ }
359
+ if (latestFileId > fileId) {
360
+ fileStream.unwatch()
361
+ }
362
+ })
363
+
364
+ return fileStream
365
+ }
366
+
367
+ streamLogFile(fileId)
368
+
369
+ const onClose = () => {
370
+ isClosed = true
371
+ watcher.close()
372
+ fileStream.destroy()
373
+ }
374
+
375
+ writableStream.on('close', onClose)
376
+ writableStream.on('error', onClose)
377
+ this.on('closed', onClose)
378
+ }
379
+
380
+ async getRuntimeMetadata () {
381
+ const packageJson = await this.#getRuntimePackageJson()
382
+ const entrypointDetails = await this.getEntrypointDetails()
383
+
384
+ return {
385
+ pid: process.pid,
386
+ cwd: process.cwd(),
387
+ argv: process.argv,
388
+ uptimeSeconds: Math.floor(process.uptime()),
389
+ execPath: process.execPath,
390
+ nodeVersion: process.version,
391
+ projectDir: this.#configManager.dirname,
392
+ packageName: packageJson.name ?? null,
393
+ packageVersion: packageJson.version ?? null,
394
+ url: entrypointDetails?.url ?? null,
395
+ platformaticVersion,
396
+ }
397
+ }
398
+
399
+ getRuntimeConfig () {
400
+ return this.#configManager.current
401
+ }
402
+
403
+ getInterceptor () {
404
+ return this.#interceptor
405
+ }
406
+
407
+ getManagementApi () {
408
+ return this.#managementApi
409
+ }
410
+
411
+ getManagementApiUrl () {
412
+ return this.#managementApi?.server.address()
413
+ }
414
+
415
+ async getEntrypointDetails () {
416
+ return this.getServiceDetails(this.#entrypointId)
417
+ }
418
+
419
+ async getServices () {
420
+ return {
421
+ entrypoint: this.#entrypointId,
422
+ services: await Promise.all(this.#servicesIds.map(id => this.getServiceDetails(id))),
423
+ }
424
+ }
425
+
426
+ async getServiceDetails (id, allowUnloaded = false) {
427
+ let service
428
+
429
+ try {
430
+ service = await this.#getServiceById(id)
431
+ } catch (e) {
432
+ if (allowUnloaded) {
433
+ return { id, status: 'stopped' }
434
+ }
435
+
436
+ throw e
437
+ }
438
+
439
+ const { entrypoint, dependencies, localUrl } = service[kConfig]
440
+
441
+ const status = await sendViaITC(service, 'getStatus')
442
+ const { type, version } = await sendViaITC(service, 'getServiceInfo')
443
+
444
+ const serviceDetails = {
445
+ id,
446
+ type,
447
+ status,
448
+ version,
449
+ localUrl,
450
+ entrypoint,
451
+ dependencies,
452
+ }
453
+
454
+ if (entrypoint) {
455
+ serviceDetails.url = status === 'started' ? this.#url : null
456
+ }
457
+
458
+ return serviceDetails
459
+ }
460
+
461
+ async getService (id) {
462
+ return this.#getServiceById(id, true)
463
+ }
464
+
465
+ async getServiceConfig (id) {
466
+ const service = await this.#getServiceById(id, true)
467
+
468
+ return sendViaITC(service, 'getServiceConfig')
469
+ }
470
+
471
+ async getServiceOpenapiSchema (id) {
472
+ const service = await this.#getServiceById(id, true)
473
+
474
+ return sendViaITC(service, 'getServiceOpenAPISchema')
475
+ }
476
+
477
+ async getServiceGraphqlSchema (id) {
478
+ const service = await this.#getServiceById(id, true)
479
+
480
+ return sendViaITC(service, 'getServiceGraphQLSchema')
481
+ }
482
+
483
+ async getMetrics (format = 'json') {
484
+ let metrics = null
485
+
486
+ for (const id of this.#servicesIds) {
487
+ try {
488
+ const service = await this.#getServiceById(id, true, false)
489
+
490
+ // The service might be temporarily unavailable
491
+ if (!service) {
492
+ continue
493
+ }
494
+
495
+ const serviceMetrics = await sendViaITC(service, 'getMetrics', format)
496
+
497
+ if (serviceMetrics) {
498
+ if (metrics === null) {
499
+ metrics = format === 'json' ? [] : ''
500
+ }
501
+
502
+ if (format === 'json') {
503
+ metrics.push(...serviceMetrics)
504
+ } else {
505
+ metrics += serviceMetrics
506
+ }
507
+ }
508
+ } catch (e) {
509
+ // The service exited while we were sending the ITC, skip it
510
+ if (e.code === 'PLT_RUNTIME_SERVICE_NOT_STARTED' || e.code === 'PLT_RUNTIME_SERVICE_EXIT') {
511
+ continue
512
+ }
513
+
514
+ throw e
515
+ }
516
+ }
517
+
518
+ return { metrics }
519
+ }
520
+
521
+ getCachedMetrics () {
522
+ return this.#metrics
523
+ }
524
+
525
+ async getFormattedMetrics () {
526
+ try {
527
+ const { metrics } = await this.getMetrics()
528
+
529
+ if (metrics === null) {
530
+ return null
531
+ }
532
+
533
+ const cpuMetric = metrics.find(metric => metric.name === 'process_cpu_percent_usage')
534
+ const rssMetric = metrics.find(metric => metric.name === 'process_resident_memory_bytes')
535
+ const totalHeapSizeMetric = metrics.find(metric => metric.name === 'nodejs_heap_size_total_bytes')
536
+ const usedHeapSizeMetric = metrics.find(metric => metric.name === 'nodejs_heap_size_used_bytes')
537
+ const heapSpaceSizeTotalMetric = metrics.find(metric => metric.name === 'nodejs_heap_space_size_total_bytes')
538
+ const newSpaceSizeTotalMetric = heapSpaceSizeTotalMetric.values.find(value => value.labels.space === 'new')
539
+ const oldSpaceSizeTotalMetric = heapSpaceSizeTotalMetric.values.find(value => value.labels.space === 'old')
540
+ const eventLoopUtilizationMetric = metrics.find(metric => metric.name === 'nodejs_eventloop_utilization')
541
+
542
+ let p50Value = 0
543
+ let p90Value = 0
544
+ let p95Value = 0
545
+ let p99Value = 0
546
+
547
+ const metricName = 'http_request_all_summary_seconds'
548
+ const httpLatencyMetrics = metrics.find(metric => metric.name === metricName)
549
+
550
+ p50Value = httpLatencyMetrics.values.find(value => value.labels.quantile === 0.5).value || 0
551
+ p90Value = httpLatencyMetrics.values.find(value => value.labels.quantile === 0.9).value || 0
552
+ p95Value = httpLatencyMetrics.values.find(value => value.labels.quantile === 0.95).value || 0
553
+ p99Value = httpLatencyMetrics.values.find(value => value.labels.quantile === 0.99).value || 0
554
+
555
+ p50Value = Math.round(p50Value * 1000)
556
+ p90Value = Math.round(p90Value * 1000)
557
+ p95Value = Math.round(p95Value * 1000)
558
+ p99Value = Math.round(p99Value * 1000)
559
+
560
+ const cpu = cpuMetric.values[0].value
561
+ const rss = rssMetric.values[0].value
562
+ const elu = eventLoopUtilizationMetric.values[0].value
563
+ const totalHeapSize = totalHeapSizeMetric.values[0].value
564
+ const usedHeapSize = usedHeapSizeMetric.values[0].value
565
+ const newSpaceSize = newSpaceSizeTotalMetric.value
566
+ const oldSpaceSize = oldSpaceSizeTotalMetric.value
567
+
568
+ const formattedMetrics = {
569
+ version: 1,
570
+ date: new Date().toISOString(),
571
+ cpu,
572
+ elu,
573
+ rss,
574
+ totalHeapSize,
575
+ usedHeapSize,
576
+ newSpaceSize,
577
+ oldSpaceSize,
578
+ entrypoint: {
579
+ latency: {
580
+ p50: p50Value,
581
+ p90: p90Value,
582
+ p95: p95Value,
583
+ p99: p99Value,
584
+ },
585
+ },
586
+ }
587
+
588
+ return formattedMetrics
589
+ } catch (err) {
590
+ // If any metric is missing, return nothing
591
+ this.logger.warn({ err }, 'Cannot fetch metrics')
592
+
593
+ return null
594
+ }
595
+ }
596
+
597
+ async getLogIds (runtimePID) {
598
+ runtimePID = runtimePID ?? process.pid
599
+
600
+ const runtimeLogFiles = await this.#getRuntimeLogFiles(runtimePID)
601
+ const runtimeLogIds = []
602
+
603
+ for (const logFile of runtimeLogFiles) {
604
+ const logId = parseInt(logFile.slice('logs.'.length))
605
+ runtimeLogIds.push(logId)
606
+ }
607
+ return runtimeLogIds
608
+ }
609
+
610
+ async getAllLogIds () {
611
+ const runtimesLogFiles = await this.#getAllLogsFiles()
612
+ const runtimesLogsIds = []
613
+
614
+ for (const runtime of runtimesLogFiles) {
615
+ const runtimeLogIds = []
616
+ for (const logFile of runtime.runtimeLogFiles) {
617
+ const logId = parseInt(logFile.slice('logs.'.length))
618
+ runtimeLogIds.push(logId)
619
+ }
620
+ runtimesLogsIds.push({
621
+ pid: runtime.runtimePID,
622
+ indexes: runtimeLogIds,
623
+ })
624
+ }
625
+
626
+ return runtimesLogsIds
627
+ }
628
+
629
+ async getLogFileStream (logFileId, runtimePID) {
630
+ const runtimeLogsDir = this.#getRuntimeLogsDir(runtimePID)
631
+ const filePath = join(runtimeLogsDir, `logs.${logFileId}`)
632
+ return createReadStream(filePath)
633
+ }
634
+
635
+ #updateStatus (status) {
636
+ this.#status = status
637
+ this.emit(status)
638
+ }
639
+
640
+ async #setupService (serviceConfig) {
641
+ const config = this.#configManager.current
642
+ const { autoload, restartOnError } = config
643
+
644
+ const id = serviceConfig.id
645
+ const { port1: loggerDestination, port2: loggingPort } = new MessageChannel()
646
+ loggerDestination.on('message', this.#forwardThreadLog.bind(this))
647
+
648
+ const service = new Worker(kWorkerFile, {
649
+ workerData: {
650
+ config,
651
+ serviceConfig,
652
+ dirname: this.#configManager.dirname,
653
+ runtimeLogsDir: this.#runtimeLogsDir,
654
+ loggingPort,
655
+ },
656
+ execArgv: [], // Avoid side effects
657
+ env: this.#env,
658
+ transferList: [loggingPort],
659
+ /*
660
+ Important: always set stdout and stderr to true, so that worker's output is not automatically
661
+ piped to the parent thread. We actually never output the thread output since we replace it
662
+ with PinoWritable, and disabling the piping avoids us to redeclare some internal Node.js methods.
663
+
664
+ The author of this (Paolo and Matteo) are not proud of the solution. Forgive us.
665
+ */
666
+ stdout: true,
667
+ stderr: true,
668
+ })
669
+
670
+ // Make sure the listener can handle a lot of API requests at once before raising a warning
671
+ service.setMaxListeners(1e3)
672
+
673
+ // Track service exiting
674
+ service.once('exit', code => {
675
+ const started = this.#startedServices.get(id)
676
+ this.#services.delete(id)
677
+ loggerDestination.close()
678
+ loggingPort.close()
679
+
680
+ // Wait for the next tick so that crashed from the thread are logged first
681
+ setImmediate(() => {
682
+ // If this was started, it means it crashed
683
+ if (started && (this.#status === 'started' || this.#status === 'starting')) {
684
+ if (restartOnError > 0) {
685
+ this.#restartCrashedService(serviceConfig, code, started)
686
+ } else {
687
+ this.logger.warn(
688
+ `Service ${id} unexpectedly exited with code ${code}. The service is no longer available ...`
689
+ )
690
+ }
691
+ }
692
+ })
693
+ })
694
+
695
+ service[kId] = id
696
+ service[kConfig] = serviceConfig
697
+
698
+ // Setup ITC
699
+ service[kITC] = new ITC({ port: service })
700
+ service[kITC].listen()
701
+
702
+ // Handle services changes
703
+ // This is not purposely activated on when this.#configManager.current.watch === true
704
+ // so that services can eventually manually trigger a restart. This mechanism is current
705
+ // used by the composer
706
+ service[kITC].on('changed', async () => {
707
+ try {
708
+ const wasStarted = this.#startedServices.get(id)
709
+
710
+ await this._stopService(id)
711
+
712
+ if (wasStarted) {
713
+ await this.startService(id)
714
+ }
715
+
716
+ this.logger.info(`Service ${id} has been successfully reloaded ...`)
717
+ } catch (e) {
718
+ this.logger.error(e)
719
+ }
720
+ })
721
+
722
+ // Store locally
723
+ this.#services.set(id, service)
724
+
725
+ if (serviceConfig.entrypoint) {
726
+ this.#entrypoint = service
727
+ this.#entrypointId = id
728
+ }
729
+
730
+ // Setup the interceptor
731
+ this.#interceptor.route(id, service)
732
+
733
+ // Store dependencies
734
+ const [{ dependencies }] = await once(service[kITC], 'init')
735
+
736
+ if (autoload) {
737
+ serviceConfig.dependencies = dependencies
738
+ for (const { envVar, url } of dependencies) {
739
+ if (envVar) {
740
+ serviceConfig.localServiceEnvVars.set(envVar, url)
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ async #restartCrashedService (serviceConfig, code, started) {
747
+ const restartTimeout = this.#configManager.current.restartOnError
748
+ const id = serviceConfig.id
749
+
750
+ this.logger.warn(`Service ${id} unexpectedly exited with code ${code}. Restarting in ${restartTimeout}ms ...`)
751
+
752
+ const timer = setTimeout(async () => {
753
+ try {
754
+ await this.#setupService(serviceConfig)
755
+
756
+ if (started) {
757
+ this.#startedServices.set(id, false)
758
+ await this.startService(id)
759
+ }
760
+ } catch (err) {
761
+ this.logger.error({ err }, `Failed to restart service ${id}.`)
762
+ }
763
+ }, restartTimeout).unref()
764
+
765
+ this.#crashedServicesTimers.set(id, timer)
766
+ }
767
+
768
+ async #getServiceById (id, ensureStarted = false, mustExist = true) {
769
+ const service = this.#services.get(id)
770
+
771
+ if (!service) {
772
+ if (!mustExist && this.#servicesIds.includes(id)) {
773
+ return null
774
+ }
775
+
776
+ throw new errors.ServiceNotFoundError(id, Array.from(this.#services.keys()).join(', '))
777
+ }
778
+
779
+ if (ensureStarted) {
780
+ const serviceStatus = await sendViaITC(service, 'getStatus')
781
+
782
+ if (serviceStatus !== 'started') {
783
+ throw new errors.ServiceNotStartedError(id)
784
+ }
785
+ }
786
+
787
+ return service
788
+ }
789
+
790
+ async #getRuntimePackageJson () {
791
+ const runtimeDir = this.#configManager.dirname
792
+ const packageJsonPath = join(runtimeDir, 'package.json')
793
+ const packageJsonFile = await readFile(packageJsonPath, 'utf8')
794
+ const packageJson = JSON.parse(packageJsonFile)
795
+ return packageJson
796
+ }
797
+
798
+ #getRuntimeLogsDir (runtimePID) {
799
+ return join(this.#runtimeTmpDir, runtimePID.toString(), 'logs')
800
+ }
801
+
802
+ async #getRuntimeLogFiles (runtimePID) {
803
+ const runtimeLogsDir = this.#getRuntimeLogsDir(runtimePID)
804
+ const runtimeLogsFiles = await readdir(runtimeLogsDir)
805
+ return runtimeLogsFiles
806
+ .filter(file => file.startsWith('logs'))
807
+ .sort((log1, log2) => {
808
+ const index1 = parseInt(log1.slice('logs.'.length))
809
+ const index2 = parseInt(log2.slice('logs.'.length))
810
+ return index1 - index2
811
+ })
812
+ }
813
+
814
+ async #getAllLogsFiles () {
815
+ try {
816
+ await access(this.#runtimeTmpDir)
817
+ } catch (err) {
818
+ this.logger.error({ err }, 'Cannot access temporary folder.')
819
+ return []
820
+ }
821
+
822
+ const runtimePIDs = await readdir(this.#runtimeTmpDir)
823
+ const runtimesLogFiles = []
824
+
825
+ for (const runtimePID of runtimePIDs) {
826
+ const runtimeLogsDir = this.#getRuntimeLogsDir(runtimePID)
827
+ const runtimeLogsDirStat = await stat(runtimeLogsDir)
828
+ const runtimeLogFiles = await this.#getRuntimeLogFiles(runtimePID)
829
+ const lastModified = runtimeLogsDirStat.mtime
830
+
831
+ runtimesLogFiles.push({
832
+ runtimePID: parseInt(runtimePID),
833
+ runtimeLogFiles,
834
+ lastModified,
835
+ })
836
+ }
837
+
838
+ return runtimesLogFiles.sort((runtime1, runtime2) => runtime1.lastModified - runtime2.lastModified)
839
+ }
840
+
841
+ #forwardThreadLog (message) {
842
+ if (!this.#loggerDestination) {
843
+ return
844
+ }
845
+
846
+ for (const log of message.logs) {
847
+ // In order to being able to forward messages serialized in the
848
+ // worker threads by directly writing to the destinations using multistream
849
+ // we unfortunately need to reparse the message to set some internal flags
850
+ // of the destination which are never set since we bypass pino.
851
+ let message = JSON.parse(log)
852
+ let { level, time, msg, raw } = message
853
+
854
+ try {
855
+ const parsed = JSON.parse(raw.trimEnd())
856
+
857
+ if (typeof parsed.level === 'number' && typeof parsed.time === 'number') {
858
+ level = parsed.level
859
+ time = parsed.time
860
+ message = parsed
861
+ } else {
862
+ message.raw = undefined
863
+ message.payload = parsed
864
+ }
865
+ } catch {
866
+ if (typeof message.raw === 'string') {
867
+ message.msg = message.raw.replace(/\n$/, '')
868
+ }
869
+
870
+ message.raw = undefined
871
+ }
872
+
873
+ this.#loggerDestination.lastLevel = level
874
+ this.#loggerDestination.lastTime = time
875
+ this.#loggerDestination.lastMsg = msg
876
+ this.#loggerDestination.lastObj = message
877
+ this.#loggerDestination.lastLogger = this.logger
878
+
879
+ // Never drop the `\n` as the worker thread trimmed the message
880
+ this.#loggerDestination.write(JSON.stringify(message) + '\n')
881
+ }
882
+ }
883
+ }
884
+
885
+ module.exports = { Runtime }