@platformatic/runtime 2.0.0-alpha.2 → 2.0.0-alpha.4

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