@platformatic/runtime 1.27.0 → 1.28.0

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://platformatic.dev/schemas/v0.33.1/service",
2
+ "$schema": "https://platformatic.dev/schemas/v0.27.0/service",
3
3
  "server": {
4
4
  "hostname": "127.0.0.1",
5
5
  "port": "{PORT}",
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const fastify = require('fastify')
4
+ const formBody = require('@fastify/formbody')
4
5
  const { createSigner } = require('fast-jwt')
5
6
 
6
7
  async function start (opts) {
@@ -15,6 +16,8 @@ async function start (opts) {
15
16
  app.decorate('refreshToken', '')
16
17
  app.decorate('signSync', signSync)
17
18
 
19
+ app.register(formBody)
20
+
18
21
  // Dump bearer token implementation
19
22
  app.post('/token', async (req, res) => {
20
23
  return { access_token: signSync({}) }
@@ -14,8 +14,9 @@
14
14
  "undici": {
15
15
  "interceptors": {
16
16
  "Agent": [{
17
- "module": "undici-oauth-interceptor",
17
+ "module": "undici-oidc-interceptor",
18
18
  "options": {
19
+ "idpTokenUrl": "{{PLT_IDP_TOKEN_URL}}",
19
20
  "refreshToken": "{{PLT_REFRESH_TOKEN}}",
20
21
  "origins": ["{{PLT_EXTERNAL_SERVICE}}"],
21
22
  "clientId": "my-client-id"
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://platformatic.dev/schemas/v1.20.0/runtime",
3
+ "entrypoint": "a",
4
+ "autoload": {
5
+ "path": "./services"
6
+ },
7
+ "server": {
8
+ "hostname": "127.0.0.1",
9
+ "port": "{{PORT}}",
10
+ "logger": {
11
+ "level": "info"
12
+ }
13
+ },
14
+ "undici": {
15
+ "interceptors": [{
16
+ "module": "undici-oidc-interceptor",
17
+ "options": {
18
+ "idpTokenUrl": "{{PLT_IDP_TOKEN_URL}}",
19
+ "refreshToken": "{{PLT_REFRESH_TOKEN}}",
20
+ "origins": ["{{PLT_EXTERNAL_SERVICE}}"],
21
+ "clientId": "my-client-id"
22
+ }
23
+ }]
24
+ }
25
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://platformatic.dev/schemas/v1.3.0/service",
3
+ "server": {
4
+ "logger": {
5
+ "level": "warn"
6
+ }
7
+ },
8
+ "plugins": {
9
+ "paths": [{
10
+ "path": "plugin.js",
11
+ "encapsulate": false,
12
+ "options": {
13
+ "externalService": "{{PLT_EXTERNAL_SERVICE}}"
14
+ }
15
+ }]
16
+ }
17
+ }
@@ -0,0 +1,15 @@
1
+ 'use strict'
2
+
3
+ const { request } = require('undici')
4
+
5
+ module.exports = async function (fastify, options) {
6
+ fastify.get('/hello', async (_, reply) => {
7
+ const res = await request(`${options.externalService}/hello`)
8
+ if (res.statusCode !== 200) {
9
+ reply.code(res.statusCode)
10
+ }
11
+ reply.log.info('response received')
12
+ const data = await res.body.json()
13
+ return data
14
+ })
15
+ }
@@ -5,5 +5,6 @@
5
5
  },
6
6
  "plugins": {
7
7
  "paths": ["plugin.js"]
8
- }
8
+ },
9
+ "metrics": true
9
10
  }
package/lib/api-client.js CHANGED
@@ -6,10 +6,14 @@ const errors = require('./errors')
6
6
  const { setTimeout: sleep } = require('node:timers/promises')
7
7
 
8
8
  const MAX_LISTENERS_COUNT = 100
9
+ const MAX_METRICS_QUEUE_LENGTH = 5 * 60 // 5 minutes in seconds
10
+ const COLLECT_METRICS_TIMEOUT = 1000
9
11
 
10
12
  class RuntimeApiClient extends EventEmitter {
11
13
  #exitCode
12
14
  #exitPromise
15
+ #metrics
16
+ #metricsTimeout
13
17
 
14
18
  constructor (worker) {
15
19
  super()
@@ -78,6 +82,10 @@ class RuntimeApiClient extends EventEmitter {
78
82
  return this.#sendCommand('plt:get-service-graphql-schema', { id })
79
83
  }
80
84
 
85
+ async getMetrics (format = 'json') {
86
+ return this.#sendCommand('plt:get-metrics', { format })
87
+ }
88
+
81
89
  async startService (id) {
82
90
  return this.#sendCommand('plt:start-service', { id })
83
91
  }
@@ -90,6 +98,115 @@ class RuntimeApiClient extends EventEmitter {
90
98
  return this.#sendCommand('plt:inject', { id, injectParams })
91
99
  }
92
100
 
101
+ getCachedMetrics () {
102
+ return this.#metrics
103
+ }
104
+
105
+ async getFormattedMetrics () {
106
+ const { metrics } = await this.getMetrics()
107
+
108
+ const entrypointDetails = await this.getEntrypointDetails()
109
+ const entrypointConfig = await this.getServiceConfig(entrypointDetails.id)
110
+ const entrypointMetricsPrefix = entrypointConfig.metrics?.prefix
111
+
112
+ const cpuMetric = metrics.find(
113
+ (metric) => metric.name === 'process_cpu_percent_usage'
114
+ )
115
+ const rssMetric = metrics.find(
116
+ (metric) => metric.name === 'process_resident_memory_bytes'
117
+ )
118
+ const totalHeapSizeMetric = metrics.find(
119
+ (metric) => metric.name === 'nodejs_heap_size_total_bytes'
120
+ )
121
+ const usedHeapSizeMetric = metrics.find(
122
+ (metric) => metric.name === 'nodejs_heap_size_used_bytes'
123
+ )
124
+ const heapSpaceSizeTotalMetric = metrics.find(
125
+ (metric) => metric.name === 'nodejs_heap_space_size_total_bytes'
126
+ )
127
+ const newSpaceSizeTotalMetric = heapSpaceSizeTotalMetric.values.find(
128
+ (value) => value.labels.space === 'new'
129
+ )
130
+ const oldSpaceSizeTotalMetric = heapSpaceSizeTotalMetric.values.find(
131
+ (value) => value.labels.space === 'old'
132
+ )
133
+ const eventLoopUtilizationMetric = metrics.find(
134
+ (metric) => metric.name === 'nodejs_eventloop_utilization'
135
+ )
136
+
137
+ let p90Value = 0
138
+ let p95Value = 0
139
+ let p99Value = 0
140
+
141
+ if (entrypointMetricsPrefix) {
142
+ const metricName = entrypointMetricsPrefix + 'http_request_all_summary_seconds'
143
+ const httpLatencyMetrics = metrics.find((metric) => metric.name === metricName)
144
+
145
+ p90Value = httpLatencyMetrics.values.find(
146
+ (value) => value.labels.quantile === 0.9
147
+ ).value || 0
148
+ p95Value = httpLatencyMetrics.values.find(
149
+ (value) => value.labels.quantile === 0.95
150
+ ).value || 0
151
+ p99Value = httpLatencyMetrics.values.find(
152
+ (value) => value.labels.quantile === 0.99
153
+ ).value || 0
154
+
155
+ p90Value = Math.round(p90Value * 1000)
156
+ p95Value = Math.round(p95Value * 1000)
157
+ p99Value = Math.round(p99Value * 1000)
158
+ }
159
+
160
+ const cpu = cpuMetric.values[0].value
161
+ const rss = rssMetric.values[0].value
162
+ const elu = eventLoopUtilizationMetric.values[0].value
163
+ const totalHeapSize = totalHeapSizeMetric.values[0].value
164
+ const usedHeapSize = usedHeapSizeMetric.values[0].value
165
+ const newSpaceSize = newSpaceSizeTotalMetric.value
166
+ const oldSpaceSize = oldSpaceSizeTotalMetric.value
167
+
168
+ const formattedMetrics = {
169
+ version: 1,
170
+ date: new Date().toISOString(),
171
+ cpu,
172
+ elu,
173
+ rss,
174
+ totalHeapSize,
175
+ usedHeapSize,
176
+ newSpaceSize,
177
+ oldSpaceSize,
178
+ entrypoint: {
179
+ latency: {
180
+ p90: p90Value,
181
+ p95: p95Value,
182
+ p99: p99Value
183
+ }
184
+ }
185
+ }
186
+ return formattedMetrics
187
+ }
188
+
189
+ startCollectingMetrics () {
190
+ this.#metrics = []
191
+ this.#metricsTimeout = setInterval(async () => {
192
+ let metrics = null
193
+ try {
194
+ metrics = await this.getFormattedMetrics()
195
+ } catch (error) {
196
+ if (!(error instanceof errors.RuntimeExitedError)) {
197
+ console.error('Error collecting metrics', error)
198
+ }
199
+ return
200
+ }
201
+
202
+ this.emit('metrics', metrics)
203
+ this.#metrics.push(metrics)
204
+ if (this.#metrics.length > MAX_METRICS_QUEUE_LENGTH) {
205
+ this.#metrics.shift()
206
+ }
207
+ }, COLLECT_METRICS_TIMEOUT).unref()
208
+ }
209
+
93
210
  async #sendCommand (command, params = {}) {
94
211
  const operationId = randomUUID()
95
212
 
@@ -113,6 +230,7 @@ class RuntimeApiClient extends EventEmitter {
113
230
  async #exitHandler () {
114
231
  this.#exitCode = undefined
115
232
  return once(this.worker, 'exit').then((msg) => {
233
+ clearInterval(this.#metricsTimeout)
116
234
  this.#exitCode = msg[0]
117
235
  return msg
118
236
  })
package/lib/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { getGlobalDispatcher, setGlobalDispatcher } = require('undici')
4
- const FastifyUndiciDispatcher = require('fastify-undici-dispatcher')
4
+ const { createFastifyInterceptor } = require('fastify-undici-dispatcher')
5
5
  const { PlatformaticApp } = require('./app')
6
6
  const errors = require('./errors')
7
7
  const { printSchema } = require('graphql')
@@ -9,9 +9,10 @@ const { printSchema } = require('graphql')
9
9
  class RuntimeApi {
10
10
  #services
11
11
  #dispatcher
12
+ #interceptor
12
13
  #logger
13
14
 
14
- constructor (config, logger, loaderPort) {
15
+ constructor (config, logger, loaderPort, composedInterceptors = []) {
15
16
  this.#services = new Map()
16
17
  this.#logger = logger
17
18
  const telemetryConfig = config.telemetry
@@ -37,11 +38,14 @@ class RuntimeApi {
37
38
  this.#services.set(service.id, app)
38
39
  }
39
40
 
40
- this.#dispatcher = new FastifyUndiciDispatcher({
41
- dispatcher: getGlobalDispatcher(),
41
+ this.#interceptor = createFastifyInterceptor({
42
42
  // setting the domain here allows for fail-fast scenarios
43
43
  domain: '.plt.local'
44
44
  })
45
+
46
+ composedInterceptors.unshift(this.#interceptor)
47
+
48
+ this.#dispatcher = getGlobalDispatcher().compose(composedInterceptors)
45
49
  setGlobalDispatcher(this.#dispatcher)
46
50
  }
47
51
 
@@ -122,6 +126,8 @@ class RuntimeApi {
122
126
  return this.#getServiceOpenapiSchema(params)
123
127
  case 'plt:get-service-graphql-schema':
124
128
  return this.#getServiceGraphqlSchema(params)
129
+ case 'plt:get-metrics':
130
+ return this.#getMetrics(params)
125
131
  case 'plt:start-service':
126
132
  return this.#startService(params)
127
133
  case 'plt:stop-service':
@@ -144,7 +150,7 @@ class RuntimeApi {
144
150
  }
145
151
 
146
152
  const serviceUrl = new URL(service.appConfig.localUrl)
147
- this.#dispatcher.route(serviceUrl.host, service.server)
153
+ this.#interceptor.route(serviceUrl.host, service.server)
148
154
  }
149
155
  return entrypointUrl
150
156
  }
@@ -172,7 +178,7 @@ class RuntimeApi {
172
178
  }
173
179
 
174
180
  const serviceUrl = new URL(service.appConfig.localUrl)
175
- this.#dispatcher.route(serviceUrl.host, service.server)
181
+ this.#interceptor.route(serviceUrl.host, service.server)
176
182
  }
177
183
  return entrypointUrl
178
184
  }
@@ -277,6 +283,33 @@ class RuntimeApi {
277
283
  }
278
284
  }
279
285
 
286
+ async #getMetrics ({ format }) {
287
+ let entrypoint = null
288
+ for (const service of this.#services.values()) {
289
+ if (service.appConfig.entrypoint) {
290
+ entrypoint = service
291
+ break
292
+ }
293
+ }
294
+
295
+ if (!entrypoint.config) {
296
+ throw new errors.ServiceNotStartedError(entrypoint.id)
297
+ }
298
+
299
+ const promRegister = entrypoint.server.metrics?.client?.register
300
+ if (!promRegister) {
301
+ return null
302
+ }
303
+
304
+ // All runtime services shares the same metrics registry.
305
+ // Getting metrics from the entrypoint returns all metrics.
306
+ const metrics = format === 'json'
307
+ ? await promRegister.getMetricsAsJSON()
308
+ : await promRegister.metrics()
309
+
310
+ return { metrics }
311
+ }
312
+
280
313
  async #startService ({ id }) {
281
314
  const service = this.#getServiceById(id)
282
315
  await service.start()
package/lib/app.js CHANGED
@@ -4,6 +4,7 @@ const { once } = require('node:events')
4
4
  const { dirname } = require('node:path')
5
5
  const { FileWatcher } = require('@platformatic/utils')
6
6
  const debounce = require('debounce')
7
+ const { snakeCase } = require('change-case-all')
7
8
  const { buildServer } = require('./build-server')
8
9
  const { loadConfig } = require('./load-config')
9
10
  const errors = require('./errors')
@@ -106,6 +107,15 @@ class PlatformaticApp {
106
107
  })
107
108
  }
108
109
 
110
+ configManager.update({
111
+ ...configManager.current,
112
+ metrics: {
113
+ ...configManager.current.metrics,
114
+ defaultMetrics: { enabled: this.appConfig.entrypoint },
115
+ prefix: snakeCase(this.appConfig.id) + '_'
116
+ }
117
+ })
118
+
109
119
  if (!this.appConfig.entrypoint) {
110
120
  configManager.update({
111
121
  ...configManager.current,
@@ -205,7 +215,7 @@ class PlatformaticApp {
205
215
  onMissingEnv (key) {
206
216
  return appConfig.localServiceEnvVars.get(key)
207
217
  }
208
- })
218
+ }, true, this.#logger)
209
219
  } catch (err) {
210
220
  this.#logAndExit(err)
211
221
  }
package/lib/config.js CHANGED
@@ -6,6 +6,7 @@ const Topo = require('@hapi/topo')
6
6
  const ConfigManager = require('@platformatic/config')
7
7
  const { schema } = require('./schema')
8
8
  const errors = require('./errors')
9
+ const upgrade = require('./upgrade')
9
10
 
10
11
  async function _transformConfig (configManager) {
11
12
  const config = configManager.current
@@ -245,6 +246,7 @@ platformaticRuntime[Symbol.for('skip-override')] = true
245
246
  platformaticRuntime.schema = schema
246
247
  platformaticRuntime.configType = 'runtime'
247
248
  platformaticRuntime.configManagerConfig = {
249
+ version: require('../package.json').version,
248
250
  schema,
249
251
  allowToWatch: ['.env'],
250
252
  schemaOptions: {
@@ -256,7 +258,8 @@ platformaticRuntime.configManagerConfig = {
256
258
  envWhitelist: ['DATABASE_URL', 'PORT', 'HOSTNAME'],
257
259
  async transformConfig () {
258
260
  await _transformConfig(this)
259
- }
261
+ },
262
+ upgrade
260
263
  }
261
264
 
262
265
  async function wrapConfigInRuntimeConfig ({ configManager, args }) {
package/lib/errors.js CHANGED
@@ -22,5 +22,6 @@ module.exports = {
22
22
  InspectorHostError: createError(`${ERROR_PREFIX}_INSPECTOR_HOST`, 'Inspector host cannot be empty'),
23
23
  CannotMapSpecifierToAbsolutePathError: createError(`${ERROR_PREFIX}_CANNOT_MAP_SPECIFIER_TO_ABSOLUTE_PATH`, 'Cannot map "%s" to an absolute path'),
24
24
  NodeInspectorFlagsNotSupportedError: createError(`${ERROR_PREFIX}_NODE_INSPECTOR_FLAGS_NOT_SUPPORTED`, 'The Node.js inspector flags are not supported. Please use \'platformatic start --inspect\' instead.'),
25
- FailedToUnlinkManagementApiSocket: createError(`${ERROR_PREFIX}_FAILED_TO_UNLINK_MANAGEMENT_API_SOCKET`, 'Failed to unlink management API socket "%s"')
25
+ FailedToUnlinkManagementApiSocket: createError(`${ERROR_PREFIX}_FAILED_TO_UNLINK_MANAGEMENT_API_SOCKET`, 'Failed to unlink management API socket "%s"'),
26
+ LogFileNotFound: createError(`${ERROR_PREFIX}_LOG_FILE_NOT_FOUND`, 'Log file with index %s not found', 404)
26
27
  }
@@ -54,11 +54,11 @@ class RuntimeGenerator extends BaseGenerator {
54
54
  const template = {
55
55
  name: `${this.runtimeName}`,
56
56
  scripts: {
57
- start: 'platformatic start',
58
- test: 'node --test test/*/*.test.js'
57
+ start: 'platformatic start'
59
58
  },
60
59
  devDependencies: {
61
- fastify: `^${this.fastifyVersion}`
60
+ fastify: `^${this.fastifyVersion}`,
61
+ borp: `${this.pkgData.devDependencies.borp}`
62
62
  },
63
63
  dependencies: {
64
64
  platformatic: `^${this.platformaticVersion}`,
@@ -0,0 +1,17 @@
1
+ 'use strict'
2
+
3
+ const { pathToFileURL } = require('node:url')
4
+
5
+ async function loadInterceptor (_require, module, options) {
6
+ const url = pathToFileURL(_require.resolve(module))
7
+ const interceptor = (await import(url)).default
8
+ return interceptor(options)
9
+ }
10
+
11
+ function loadInterceptors (_require, interceptors) {
12
+ return Promise.all(interceptors.map(async ({ module, options }) => {
13
+ return loadInterceptor(_require, module, options)
14
+ }))
15
+ }
16
+
17
+ module.exports = loadInterceptors
package/lib/logs.js ADDED
@@ -0,0 +1,112 @@
1
+ 'use strict'
2
+
3
+ const { tmpdir } = require('node:os')
4
+ const { join } = require('node:path')
5
+ const { createReadStream, watch } = require('node:fs')
6
+ const { readdir } = require('node:fs/promises')
7
+ const ts = require('tail-file-stream')
8
+
9
+ const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'runtimes')
10
+ const runtimeTmpDir = join(PLATFORMATIC_TMP_DIR, process.pid.toString())
11
+
12
+ async function getLogFiles () {
13
+ const runtimeTmpFiles = await readdir(runtimeTmpDir)
14
+ const runtimeLogFiles = runtimeTmpFiles
15
+ .filter((file) => file.startsWith('logs'))
16
+ .sort((log1, log2) => {
17
+ const index1 = parseInt(log1.slice('logs.'.length))
18
+ const index2 = parseInt(log2.slice('logs.'.length))
19
+ return index1 - index2
20
+ })
21
+ return runtimeLogFiles
22
+ }
23
+
24
+ async function pipeLiveLogs (writableStream, logger, startLogIndex) {
25
+ const runtimeLogFiles = await getLogFiles()
26
+ if (runtimeLogFiles.length === 0) {
27
+ writableStream.end()
28
+ return
29
+ }
30
+
31
+ let latestFileIndex = parseInt(runtimeLogFiles.at(-1).slice('logs.'.length))
32
+
33
+ let waiting = false
34
+ let fileStream = null
35
+ let fileIndex = startLogIndex ?? latestFileIndex
36
+
37
+ const watcher = watch(runtimeTmpDir, async (event, filename) => {
38
+ if (event === 'rename' && filename.startsWith('logs')) {
39
+ const logFileIndex = parseInt(filename.slice('logs.'.length))
40
+ if (logFileIndex > latestFileIndex) {
41
+ latestFileIndex = logFileIndex
42
+ if (waiting) {
43
+ streamLogFile(++fileIndex)
44
+ }
45
+ }
46
+ }
47
+ }).unref()
48
+
49
+ const streamLogFile = () => {
50
+ const fileName = 'logs.' + fileIndex
51
+ const filePath = join(runtimeTmpDir, fileName)
52
+
53
+ const prevFileStream = fileStream
54
+
55
+ fileStream = ts.createReadStream(filePath)
56
+ fileStream.pipe(writableStream, { end: false })
57
+
58
+ if (prevFileStream) {
59
+ prevFileStream.unpipe(writableStream)
60
+ prevFileStream.destroy()
61
+ }
62
+
63
+ fileStream.on('error', (err) => {
64
+ logger.log.error(err, 'Error streaming log file')
65
+ fileStream.destroy()
66
+ watcher.close()
67
+ writableStream.end()
68
+ })
69
+
70
+ fileStream.on('data', () => {
71
+ waiting = false
72
+ })
73
+
74
+ fileStream.on('eof', () => {
75
+ if (latestFileIndex > fileIndex) {
76
+ streamLogFile(++fileIndex)
77
+ } else {
78
+ waiting = true
79
+ }
80
+ })
81
+
82
+ return fileStream
83
+ }
84
+
85
+ streamLogFile(fileIndex)
86
+
87
+ writableStream.on('close', () => {
88
+ watcher.close()
89
+ fileStream.destroy()
90
+ })
91
+ writableStream.on('error', () => {
92
+ watcher.close()
93
+ fileStream.destroy()
94
+ })
95
+ }
96
+
97
+ async function getLogIndexes () {
98
+ const runtimeLogFiles = await getLogFiles()
99
+ return runtimeLogFiles
100
+ .map((file) => parseInt(file.slice('logs.'.length)))
101
+ }
102
+
103
+ async function getLogFileStream (logFileIndex) {
104
+ const filePath = join(runtimeTmpDir, `logs.${logFileIndex}`)
105
+ return createReadStream(filePath)
106
+ }
107
+
108
+ module.exports = {
109
+ pipeLiveLogs,
110
+ getLogFileStream,
111
+ getLogIndexes
112
+ }
@@ -2,16 +2,16 @@
2
2
 
3
3
  const { tmpdir, platform } = require('node:os')
4
4
  const { join } = require('node:path')
5
- const { createReadStream } = require('node:fs')
6
- const { readFile, readdir, mkdir, unlink } = require('node:fs/promises')
5
+ const { readFile, mkdir, unlink } = require('node:fs/promises')
7
6
  const fastify = require('fastify')
8
7
  const errors = require('./errors')
8
+ const { pipeLiveLogs, getLogFileStream, getLogIndexes } = require('./logs')
9
9
  const platformaticVersion = require('../package.json').version
10
10
 
11
11
  const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'runtimes')
12
12
  const runtimeTmpDir = join(PLATFORMATIC_TMP_DIR, process.pid.toString())
13
13
 
14
- async function createManagementApi (configManager, runtimeApiClient, loggingPort) {
14
+ async function createManagementApi (configManager, runtimeApiClient) {
15
15
  const app = fastify()
16
16
  app.log.warn(
17
17
  'Runtime Management API is in the experimental stage. ' +
@@ -115,64 +115,65 @@ async function createManagementApi (configManager, runtimeApiClient, loggingPort
115
115
  .send(res.body)
116
116
  })
117
117
 
118
- app.get('/logs/live', { websocket: true }, async (connection, req) => {
119
- const handler = (message) => {
120
- for (const log of message.logs) {
121
- connection.socket.send(log)
122
- }
118
+ app.get('/metrics/live', { websocket: true }, async (connection) => {
119
+ const cachedMetrics = runtimeApiClient.getCachedMetrics()
120
+ const serializedMetrics = cachedMetrics
121
+ .map((metric) => JSON.stringify(metric))
122
+ .join('\n')
123
+ connection.socket.send(serializedMetrics)
124
+
125
+ const eventHandler = (metrics) => {
126
+ const serializedMetrics = JSON.stringify(metrics)
127
+ connection.socket.send(serializedMetrics)
123
128
  }
124
129
 
125
- loggingPort.on('message', handler)
126
- connection.socket.on('close', () => {
127
- loggingPort.off('message', handler)
128
- })
129
- connection.socket.on('error', () => {
130
- loggingPort.off('message', handler)
130
+ runtimeApiClient.on('metrics', eventHandler)
131
+
132
+ connection.on('error', () => {
133
+ runtimeApiClient.off('metrics', eventHandler)
131
134
  })
132
- connection.socket.on('end', () => {
133
- loggingPort.off('message', handler)
135
+
136
+ connection.on('close', () => {
137
+ runtimeApiClient.off('metrics', eventHandler)
134
138
  })
135
139
  })
136
140
 
137
- app.get('/logs/history', { websocket: true }, async (connection, req) => {
138
- const runtimeTmpFiles = await readdir(runtimeTmpDir)
139
- const runtimeLogFiles = runtimeTmpFiles
140
- .filter((file) => file.startsWith('logs'))
141
- .map((file) => join(runtimeTmpDir, file))
142
- .sort()
141
+ app.get('/logs/live', { websocket: true }, async (connection, req) => {
142
+ const startLogIndex = req.query.start ? parseInt(req.query.start) : null
143
143
 
144
- if (runtimeLogFiles.length === 0) {
145
- connection.end()
146
- return
144
+ if (startLogIndex) {
145
+ const logIndexes = await getLogIndexes()
146
+ if (!logIndexes.includes(startLogIndex)) {
147
+ throw new errors.LogFileNotFound(startLogIndex)
148
+ }
147
149
  }
148
150
 
149
- const streamLogFile = (fileIndex) => {
150
- const file = runtimeLogFiles[fileIndex]
151
- const isLastFile = fileIndex === runtimeLogFiles.length - 1
152
-
153
- const stream = createReadStream(file)
154
- stream.pipe(connection, { end: isLastFile })
155
-
156
- stream.on('error', (err) => {
157
- app.log.error(err, 'Error streaming log file')
158
- connection.end()
159
- })
160
- stream.on('end', () => {
161
- if (isLastFile) {
162
- connection.end()
163
- return
164
- }
165
- streamLogFile(fileIndex + 1)
166
- })
151
+ pipeLiveLogs(connection, req.log, startLogIndex)
152
+ })
153
+
154
+ app.get('/logs/indexes', async () => {
155
+ const logIndexes = await getLogIndexes()
156
+ return { indexes: logIndexes }
157
+ })
158
+
159
+ app.get('/logs/:id', async (req) => {
160
+ const { id } = req.params
161
+
162
+ const logIndex = parseInt(id)
163
+ const logIndexes = await getLogIndexes()
164
+ if (!logIndexes.includes(logIndex)) {
165
+ throw new errors.LogFileNotFound(logIndex)
167
166
  }
168
- streamLogFile(0)
167
+
168
+ const logFileStream = await getLogFileStream(logIndex)
169
+ return logFileStream
169
170
  })
170
171
  }, { prefix: '/api/v1' })
171
172
 
172
173
  return app
173
174
  }
174
175
 
175
- async function startManagementApi (configManager, runtimeApiClient, loggingPort) {
176
+ async function startManagementApi (configManager, runtimeApiClient) {
176
177
  const runtimePID = process.pid
177
178
 
178
179
  let socketPath = null
@@ -192,8 +193,7 @@ async function startManagementApi (configManager, runtimeApiClient, loggingPort)
192
193
 
193
194
  const managementApi = await createManagementApi(
194
195
  configManager,
195
- runtimeApiClient,
196
- loggingPort
196
+ runtimeApiClient
197
197
  )
198
198
 
199
199
  if (platform() !== 'win32') {
package/lib/schema.js CHANGED
@@ -106,24 +106,41 @@ const platformaticRuntimeSchema = {
106
106
  }
107
107
  },
108
108
  undici: {
109
- agentOptions: {
110
- type: 'object',
111
- additionalProperties: true
112
- },
113
- interceptors: {
114
- type: 'array',
115
- items: {
109
+ type: 'object',
110
+ properties: {
111
+ agentOptions: {
116
112
  type: 'object',
117
- properties: {
118
- module: {
119
- type: 'string'
120
- },
121
- options: {
122
- type: 'object',
123
- additionalProperties: true
113
+ additionalProperties: true
114
+ },
115
+ interceptors: {
116
+ anyOf: [{
117
+ type: 'array',
118
+ items: {
119
+ $ref: '#/$defs/undiciInterceptor'
124
120
  }
125
- },
126
- required: ['module', 'options']
121
+ }, {
122
+ type: 'object',
123
+ properties: {
124
+ Client: {
125
+ type: 'array',
126
+ items: {
127
+ $ref: '#/$defs/undiciInterceptor'
128
+ }
129
+ },
130
+ Pool: {
131
+ type: 'array',
132
+ items: {
133
+ $ref: '#/$defs/undiciInterceptor'
134
+ }
135
+ },
136
+ Agent: {
137
+ type: 'array',
138
+ items: {
139
+ $ref: '#/$defs/undiciInterceptor'
140
+ }
141
+ }
142
+ }
143
+ }]
127
144
  }
128
145
  }
129
146
  },
@@ -144,7 +161,22 @@ const platformaticRuntimeSchema = {
144
161
  { required: ['autoload', 'entrypoint'] },
145
162
  { required: ['services', 'entrypoint'] }
146
163
  ],
147
- additionalProperties: false
164
+ additionalProperties: false,
165
+ $defs: {
166
+ undiciInterceptor: {
167
+ type: 'object',
168
+ properties: {
169
+ module: {
170
+ type: 'string'
171
+ },
172
+ options: {
173
+ type: 'object',
174
+ additionalProperties: true
175
+ }
176
+ },
177
+ required: ['module', 'options']
178
+ }
179
+ }
148
180
  }
149
181
 
150
182
  module.exports.schema = platformaticRuntimeSchema
package/lib/start.js CHANGED
@@ -44,21 +44,10 @@ async function startWithConfig (configManager, env = process.env) {
44
44
  // The configManager cannot be transferred to the worker, so remove it.
45
45
  delete config.configManager
46
46
 
47
- let mainLoggingPort = null
48
- let childLoggingPort = config.loggingPort
49
-
50
- if (!childLoggingPort && config.managementApi) {
51
- const { port1, port2 } = new MessageChannel()
52
- mainLoggingPort = port1
53
- childLoggingPort = port2
54
-
55
- config.loggingPort = childLoggingPort
56
- }
57
-
58
47
  const worker = new Worker(kWorkerFile, {
59
48
  /* c8 ignore next */
60
49
  execArgv: config.hotReload ? kWorkerExecArgv : [],
61
- transferList: childLoggingPort ? [childLoggingPort] : [],
50
+ transferList: config.loggingPort ? [config.loggingPort] : [],
62
51
  workerData: { config, dirname },
63
52
  env
64
53
  })
@@ -116,10 +105,10 @@ async function startWithConfig (configManager, env = process.env) {
116
105
  if (config.managementApi) {
117
106
  managementApi = await startManagementApi(
118
107
  configManager,
119
- runtimeApiClient,
120
- mainLoggingPort
108
+ runtimeApiClient
121
109
  )
122
110
  runtimeApiClient.managementApi = managementApi
111
+ runtimeApiClient.startCollectingMetrics()
123
112
  }
124
113
 
125
114
  return runtimeApiClient
package/lib/upgrade.js ADDED
@@ -0,0 +1,25 @@
1
+ 'use strict'
2
+
3
+ const { join } = require('path')
4
+ const pkg = require('../package.json')
5
+
6
+ module.exports = async function upgrade (config, version) {
7
+ const { semgrator } = await import('semgrator')
8
+
9
+ const iterator = semgrator({
10
+ version,
11
+ path: join(__dirname, 'versions'),
12
+ input: config,
13
+ logger: this.logger.child({ name: '@platformatic/runtime' })
14
+ })
15
+
16
+ let result
17
+
18
+ for await (const updated of iterator) {
19
+ result = updated.result
20
+ }
21
+
22
+ result.$schema = `https://platformatic.dev/schemas/v${pkg.version}/runtime`
23
+
24
+ return result
25
+ }
@@ -0,0 +1,11 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ version: '1.5.0',
5
+ up: function (config) {
6
+ if (config.watch !== undefined) {
7
+ delete config.watch
8
+ }
9
+ return config
10
+ }
11
+ }
package/lib/worker.js CHANGED
@@ -4,7 +4,6 @@ const inspector = require('node:inspector')
4
4
  const { tmpdir } = require('node:os')
5
5
  const { register, createRequire } = require('node:module')
6
6
  const { isatty } = require('node:tty')
7
- const { pathToFileURL } = require('node:url')
8
7
  const { join } = require('node:path')
9
8
  const {
10
9
  MessageChannel,
@@ -17,6 +16,7 @@ const pretty = require('pino-pretty')
17
16
  const { setGlobalDispatcher, Agent } = require('undici')
18
17
  const RuntimeApi = require('./api')
19
18
  const { MessagePortWritable } = require('./message-port-writable')
19
+ const loadInterceptors = require('./interceptors')
20
20
  let loaderPort
21
21
 
22
22
  const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'runtimes')
@@ -64,7 +64,7 @@ function createLogger (config) {
64
64
  const logsPath = join(PLATFORMATIC_TMP_DIR, process.pid.toString(), 'logs')
65
65
  const pinoRoll = pino.transport({
66
66
  target: 'pino-roll',
67
- options: { file: logsPath, size: '5m', mkdir: true }
67
+ options: { file: logsPath, mode: 0o600, size: '5m', mkdir: true }
68
68
  })
69
69
  multiStream.add({ level: 'trace', stream: pinoRoll })
70
70
  }
@@ -105,18 +105,6 @@ process.on('unhandledRejection', (err) => {
105
105
  }
106
106
  })
107
107
 
108
- async function loadInterceptor (_require, module, options) {
109
- const url = pathToFileURL(_require.resolve(module))
110
- const interceptor = (await import(url)).default
111
- return interceptor(options)
112
- }
113
-
114
- function loadInterceptors (_require, interceptors) {
115
- return Promise.all(interceptors.map(async ({ module, options }) => {
116
- return loadInterceptor(_require, module, options)
117
- }))
118
- }
119
-
120
108
  async function main () {
121
109
  const { inspectorOptions } = workerData.config
122
110
 
@@ -130,7 +118,7 @@ async function main () {
130
118
  }
131
119
 
132
120
  const interceptors = {}
133
-
121
+ const composedInterceptors = []
134
122
  if (config.undici?.interceptors) {
135
123
  const _require = createRequire(join(workerData.dirname, 'package.json'))
136
124
  for (const key of ['Agent', 'Pool', 'Client']) {
@@ -138,6 +126,10 @@ async function main () {
138
126
  interceptors[key] = await loadInterceptors(_require, config.undici.interceptors[key])
139
127
  }
140
128
  }
129
+
130
+ if (Array.isArray(config.undici.interceptors)) {
131
+ composedInterceptors.push(...await loadInterceptors(_require, config.undici.interceptors))
132
+ }
141
133
  }
142
134
 
143
135
  const globalDispatcher = new Agent({
@@ -146,7 +138,7 @@ async function main () {
146
138
  })
147
139
  setGlobalDispatcher(globalDispatcher)
148
140
 
149
- const runtime = new RuntimeApi(workerData.config, logger, loaderPort)
141
+ const runtime = new RuntimeApi(workerData.config, logger, loaderPort, composedInterceptors)
150
142
  runtime.startListening(parentPort)
151
143
 
152
144
  parentPort.postMessage('plt:init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -18,51 +18,56 @@
18
18
  "homepage": "https://github.com/platformatic/platformatic#readme",
19
19
  "devDependencies": {
20
20
  "@fastify/express": "^2.3.0",
21
+ "@fastify/formbody": "^7.4.0",
21
22
  "@matteo.collina/tspl": "^0.1.1",
22
23
  "borp": "^0.10.0",
23
24
  "c8": "^9.1.0",
24
25
  "execa": "^8.0.1",
25
- "express": "^4.18.2",
26
+ "express": "^4.18.3",
26
27
  "fast-jwt": "^3.3.3",
27
28
  "pino-abstract-transport": "^1.1.0",
28
29
  "snazzy": "^9.0.0",
29
30
  "split2": "^4.2.0",
30
31
  "standard": "^17.1.0",
31
- "tsd": "^0.30.4",
32
- "typescript": "^5.3.3",
33
- "undici-oauth-interceptor": "^0.4.2",
32
+ "tsd": "^0.30.7",
33
+ "typescript": "^5.4.2",
34
+ "undici-oidc-interceptor": "^0.5.0",
35
+ "why-is-node-running": "^2.2.2",
34
36
  "ws": "^8.16.0",
35
- "@platformatic/sql-graphql": "1.27.0",
36
- "@platformatic/sql-mapper": "1.27.0"
37
+ "@platformatic/sql-graphql": "1.28.0",
38
+ "@platformatic/sql-mapper": "1.28.0"
37
39
  },
38
40
  "dependencies": {
39
41
  "@fastify/error": "^3.4.1",
40
42
  "@fastify/websocket": "^9.0.0",
41
43
  "@hapi/topo": "^6.0.2",
42
44
  "boring-name-generator": "^1.0.3",
43
- "close-with-grace": "^1.2.0",
45
+ "change-case-all": "^2.1.0",
46
+ "close-with-grace": "^1.3.0",
44
47
  "commist": "^3.2.0",
45
48
  "debounce": "^2.0.0",
46
49
  "desm": "^1.3.1",
47
50
  "es-main": "^1.3.0",
48
51
  "fastest-levenshtein": "^1.0.16",
49
- "fastify": "^4.26.0",
50
- "fastify-undici-dispatcher": "^0.5.0",
52
+ "fastify": "^4.26.2",
53
+ "fastify-undici-dispatcher": "^0.6.0",
51
54
  "graphql": "^16.8.1",
52
55
  "help-me": "^5.0.0",
53
56
  "minimist": "^1.2.8",
54
- "pino": "^8.17.2",
57
+ "pino": "^8.19.0",
55
58
  "pino-pretty": "^10.3.1",
59
+ "semgrator": "^0.3.0",
56
60
  "pino-roll": "1.0.0-rc.1",
57
- "undici": "^6.6.0",
61
+ "tail-file-stream": "^0.1.0",
62
+ "undici": "^6.9.0",
58
63
  "why-is-node-running": "^2.2.2",
59
- "@platformatic/composer": "1.27.0",
60
- "@platformatic/config": "1.27.0",
61
- "@platformatic/generators": "1.27.0",
62
- "@platformatic/db": "1.27.0",
63
- "@platformatic/service": "1.27.0",
64
- "@platformatic/utils": "1.27.0",
65
- "@platformatic/telemetry": "1.27.0"
64
+ "@platformatic/db": "1.28.0",
65
+ "@platformatic/config": "1.28.0",
66
+ "@platformatic/generators": "1.28.0",
67
+ "@platformatic/service": "1.28.0",
68
+ "@platformatic/composer": "1.28.0",
69
+ "@platformatic/utils": "1.28.0",
70
+ "@platformatic/telemetry": "1.28.0"
66
71
  },
67
72
  "standard": {
68
73
  "ignore": [