@platformatic/runtime 2.0.0-alpha.1 → 2.0.0-alpha.11

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