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

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