@platformatic/runtime 3.11.0 → 3.13.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.
package/config.d.ts CHANGED
@@ -413,4 +413,13 @@ export type PlatformaticRuntimeConfig = {
413
413
  maxRetries?: number;
414
414
  [k: string]: unknown;
415
415
  }[];
416
+ policies?: {
417
+ deny: {
418
+ /**
419
+ * This interface was referenced by `undefined`'s JSON-Schema definition
420
+ * via the `patternProperty` "^.*$".
421
+ */
422
+ [k: string]: string | [string, ...string[]];
423
+ };
424
+ };
416
425
  };
package/index.js CHANGED
@@ -44,7 +44,7 @@ function handleSignal (runtime, config) {
44
44
 
45
45
  const cwg = closeWithGrace({ delay: config.gracefulShutdown?.runtime ?? 10000, onTimeout }, async event => {
46
46
  if (event.err instanceof Error) {
47
- console.error(event.err)
47
+ console.error(new Error('@platformatic/runtime threw an unexpected error', { cause: event.err }))
48
48
  }
49
49
  await runtime.close()
50
50
  })
package/lib/config.js CHANGED
@@ -335,13 +335,21 @@ export async function transform (config, _, context) {
335
335
  // like adding other applications.
336
336
  }
337
337
 
338
- if (config.metrics === true) {
338
+ if (typeof config.metrics === 'boolean') {
339
339
  config.metrics = {
340
- enabled: true,
340
+ enabled: config.metrics,
341
341
  timeout: 1000
342
342
  }
343
343
  }
344
344
 
345
+ if (config.policies?.deny) {
346
+ for (const [from, to] of Object.entries(config.policies.deny)) {
347
+ if (typeof to === 'string') {
348
+ config.policies.deny[from] = [to]
349
+ }
350
+ }
351
+ }
352
+
345
353
  config.applications = applications
346
354
  config.web = undefined
347
355
  config.services = undefined
@@ -132,6 +132,17 @@ export async function managementApiPlugin (app, opts) {
132
132
  })
133
133
 
134
134
  app.get('/metrics', { logLevel: 'debug' }, async (req, reply) => {
135
+ const config = await runtime.getRuntimeConfig()
136
+
137
+ if (config.metrics?.enabled === false) {
138
+ reply.code(501)
139
+ return {
140
+ statusCode: 501,
141
+ error: 'Not Implemented',
142
+ message: 'Metrics are disabled.'
143
+ }
144
+ }
145
+
135
146
  const accepts = req.accepts()
136
147
 
137
148
  if (!accepts.type('text/plain') && accepts.type('application/json')) {
@@ -145,6 +156,23 @@ export async function managementApiPlugin (app, opts) {
145
156
  })
146
157
 
147
158
  app.get('/metrics/live', { websocket: true }, async socket => {
159
+ const config = await runtime.getRuntimeConfig()
160
+
161
+ if (config.metrics?.enabled === false) {
162
+ socket.send(
163
+ JSON.stringify({
164
+ statusCode: 501,
165
+ error: 'Not Implemented',
166
+ message: 'Metrics are disabled.'
167
+ }),
168
+ () => {
169
+ socket.close()
170
+ }
171
+ )
172
+
173
+ return
174
+ }
175
+
148
176
  const cachedMetrics = runtime.getCachedMetrics()
149
177
  if (cachedMetrics.length > 0) {
150
178
  const serializedMetrics = cachedMetrics.map(metric => JSON.stringify(metric)).join('\n')
@@ -0,0 +1,23 @@
1
+ export function createChannelCreationHook (config) {
2
+ const denyList = config.policies?.deny
3
+
4
+ if (typeof denyList === 'undefined') {
5
+ return undefined
6
+ }
7
+
8
+ const forbidden = new Set()
9
+
10
+ for (let [first, unalloweds] of Object.entries(denyList)) {
11
+ for (let second of unalloweds) {
12
+ first = first.toLowerCase()
13
+ second = second.toLowerCase()
14
+
15
+ forbidden.add(`${first}:${second}`)
16
+ forbidden.add(`${second}:${first}`)
17
+ }
18
+ }
19
+
20
+ return function channelCreationHook (first, second) {
21
+ return !forbidden.has(`${first.toLowerCase()}:${second.toLowerCase()}`)
22
+ }
23
+ }
package/lib/runtime.js CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  } from './errors.js'
41
41
  import { abstractLogger, createLogger } from './logger.js'
42
42
  import { startManagementApi } from './management-api.js'
43
+ import { getMemoryInfo } from './metrics.js'
44
+ import { createChannelCreationHook } from './policies.js'
43
45
  import { startPrometheusServer } from './prom-server.js'
44
46
  import ScalingAlgorithm from './scaling-algorithm.js'
45
47
  import { startScheduler } from './scheduler.js'
@@ -47,7 +49,6 @@ import { createSharedStore } from './shared-http-cache.js'
47
49
  import { version } from './version.js'
48
50
  import { sendViaITC, waitEventFromITC } from './worker/itc.js'
49
51
  import { RoundRobinMap } from './worker/round-robin-map.js'
50
- import { getMemoryInfo } from './metrics.js'
51
52
  import {
52
53
  kApplicationId,
53
54
  kConfig,
@@ -59,8 +60,8 @@ import {
59
60
  kStderrMarker,
60
61
  kWorkerId,
61
62
  kWorkersBroadcast,
62
- kWorkerStatus,
63
- kWorkerStartTime
63
+ kWorkerStartTime,
64
+ kWorkerStatus
64
65
  } from './worker/symbols.js'
65
66
 
66
67
  const kWorkerFile = join(import.meta.dirname, 'worker/main.js')
@@ -113,6 +114,8 @@ export class Runtime extends EventEmitter {
113
114
  #sharedHttpCache
114
115
  #scheduler
115
116
 
117
+ #channelCreationHook
118
+
116
119
  constructor (config, context) {
117
120
  super()
118
121
  this.setMaxListeners(MAX_LISTENERS_COUNT)
@@ -125,7 +128,12 @@ export class Runtime extends EventEmitter {
125
128
  this.#concurrency = this.#context.concurrency ?? MAX_CONCURRENCY
126
129
  this.#workers = new RoundRobinMap()
127
130
  this.#url = undefined
128
- this.#meshInterceptor = createThreadInterceptor({ domain: '.plt.local', timeout: this.#config.applicationTimeout })
131
+ this.#channelCreationHook = createChannelCreationHook(this.#config)
132
+ this.#meshInterceptor = createThreadInterceptor({
133
+ domain: '.plt.local',
134
+ timeout: this.#config.applicationTimeout,
135
+ onChannelCreation: this.#channelCreationHook
136
+ })
129
137
  this.logger = abstractLogger // This is replaced by the real logger in init() and eventually removed in close()
130
138
  this.#status = undefined
131
139
  this.#restartingWorkers = new Map()
@@ -275,7 +283,7 @@ export class Runtime extends EventEmitter {
275
283
 
276
284
  this.#updateStatus('started')
277
285
 
278
- if (this.#managementApi && typeof this.#metrics === 'undefined') {
286
+ if (this.#config.metrics?.enabled !== false && typeof this.#metrics === 'undefined') {
279
287
  this.startCollectingMetrics()
280
288
  }
281
289
 
@@ -1606,7 +1614,7 @@ export class Runtime extends EventEmitter {
1606
1614
  } else {
1607
1615
  worker[kHealthCheckTimer].refresh()
1608
1616
  }
1609
- }, interval)
1617
+ }, interval).unref()
1610
1618
  }
1611
1619
 
1612
1620
  async #startWorker (
@@ -2003,7 +2011,14 @@ export class Runtime extends EventEmitter {
2003
2011
  }
2004
2012
  }
2005
2013
 
2006
- async #getWorkerMessagingChannel ({ application, worker }, context) {
2014
+ async #getWorkerMessagingChannel ({ id, application, worker }, context) {
2015
+ if (this.#channelCreationHook?.(id, application) === false) {
2016
+ throw new MessagingError(
2017
+ application,
2018
+ `Communication channels are disabled between applications "${id}" and "${application}".`
2019
+ )
2020
+ }
2021
+
2007
2022
  const target = await this.#getWorkerById(application, worker, true, true)
2008
2023
 
2009
2024
  const { port1, port2 } = new MessageChannel()
@@ -2085,8 +2100,15 @@ export class Runtime extends EventEmitter {
2085
2100
  }
2086
2101
  }
2087
2102
 
2088
- const pinoLog =
2089
- typeof message?.level === 'number' && typeof message?.time === 'number' && typeof message?.msg === 'string'
2103
+ let pinoLog
2104
+
2105
+ if (typeof message === 'object') {
2106
+ pinoLog =
2107
+ typeof message.level === 'number' &&
2108
+ // We want to accept both pino raw time (number) and time as formatted string
2109
+ (typeof message.time === 'number' || typeof message.time === 'string') &&
2110
+ typeof message.msg === 'string'
2111
+ }
2090
2112
 
2091
2113
  // Directly write to the Pino destination
2092
2114
  if (pinoLog) {
@@ -2467,9 +2489,7 @@ export class Runtime extends EventEmitter {
2467
2489
  async #setupVerticalScaler () {
2468
2490
  const fixedWorkersCount = this.#config.workers
2469
2491
  if (fixedWorkersCount !== undefined) {
2470
- this.logger.warn(
2471
- `Vertical scaler disabled because the "workers" configuration is set to ${fixedWorkersCount}`
2472
- )
2492
+ this.logger.warn(`Vertical scaler disabled because the "workers" configuration is set to ${fixedWorkersCount}`)
2473
2493
  return
2474
2494
  }
2475
2495
 
@@ -2510,7 +2530,7 @@ export class Runtime extends EventEmitter {
2510
2530
  if (application.entrypoint && !features.node.reusePort) {
2511
2531
  this.logger.warn(
2512
2532
  `The "${application.id}" application cannot be scaled because it is an entrypoint` +
2513
- ' and the "reusePort" feature is not available in your OS.'
2533
+ ' and the "reusePort" feature is not available in your OS.'
2514
2534
  )
2515
2535
 
2516
2536
  applicationsConfigs[application.id] = {
@@ -2522,7 +2542,7 @@ export class Runtime extends EventEmitter {
2522
2542
  if (application.workers !== undefined) {
2523
2543
  this.logger.warn(
2524
2544
  `The "${application.id}" application cannot be scaled because` +
2525
- ` it has a fixed number of workers (${application.workers}).`
2545
+ ` it has a fixed number of workers (${application.workers}).`
2526
2546
  )
2527
2547
  applicationsConfigs[application.id] = {
2528
2548
  minWorkers: application.workers,
@@ -2574,10 +2594,7 @@ export class Runtime extends EventEmitter {
2574
2594
  const now = Date.now()
2575
2595
 
2576
2596
  for (const worker of this.#workers.values()) {
2577
- if (
2578
- worker[kWorkerStatus] !== 'started' ||
2579
- worker[kWorkerStartTime] + gracePeriod > now
2580
- ) {
2597
+ if (worker[kWorkerStatus] !== 'started' || worker[kWorkerStartTime] + gracePeriod > now) {
2581
2598
  continue
2582
2599
  }
2583
2600
 
@@ -160,12 +160,13 @@ export class Controller extends EventEmitter {
160
160
  this.#logAndThrow(err)
161
161
  }
162
162
 
163
- this.emit('starting')
164
-
165
163
  if (this.capability.status === 'stopped') {
166
164
  return
167
165
  }
168
166
 
167
+ this.capability.updateStatus('starting')
168
+ this.emit('starting')
169
+
169
170
  if (this.#watch) {
170
171
  const watchConfig = await this.capability.getWatchConfig()
171
172
 
@@ -187,6 +188,9 @@ export class Controller extends EventEmitter {
187
188
  this.#listening = listen
188
189
  /* c8 ignore next 5 */
189
190
  } catch (err) {
191
+ this.capability.updateStatus('start:error')
192
+ this.emit('start:error', err)
193
+
190
194
  this.capability.log({ message: err.message, level: 'debug' })
191
195
  this.#starting = false
192
196
  throw err
@@ -194,6 +198,8 @@ export class Controller extends EventEmitter {
194
198
 
195
199
  this.#started = true
196
200
  this.#starting = false
201
+
202
+ this.capability.updateStatus('started')
197
203
  this.emit('started')
198
204
  }
199
205
 
@@ -203,6 +209,10 @@ export class Controller extends EventEmitter {
203
209
  }
204
210
 
205
211
  this.emit('stopping')
212
+ // Do not update status of the capability to "stopping" here otherwise
213
+ // if stop is called before start is finished, the capability will not
214
+ // be able to wait for start to finish and it will create a race condition.
215
+
206
216
  await this.#stopFileWatching()
207
217
  await this.capability.waitForDependentsStop(dependents)
208
218
  await this.capability.stop()
@@ -210,6 +220,8 @@ export class Controller extends EventEmitter {
210
220
  this.#started = false
211
221
  this.#starting = false
212
222
  this.#listening = false
223
+
224
+ this.capability.updateStatus('stopped')
213
225
  this.emit('stopped')
214
226
  }
215
227
 
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'
5
5
  import { parentPort, workerData } from 'node:worker_threads'
6
6
  import { Agent, Client, Pool, setGlobalDispatcher } from 'undici'
7
7
  import { wire } from 'undici-thread-interceptor'
8
+ import { createChannelCreationHook } from '../policies.js'
8
9
  import { RemoteCacheStore, httpCacheInterceptor } from './http-cache.js'
9
10
  import { kInterceptors } from './symbols.js'
10
11
 
@@ -171,6 +172,7 @@ function createThreadInterceptor (runtimeConfig) {
171
172
  domain: '.plt.local',
172
173
  port: parentPort,
173
174
  timeout: runtimeConfig.applicationTimeout,
175
+ onChannelCreation: createChannelCreationHook(runtimeConfig),
174
176
  ...telemetryHooks
175
177
  })
176
178
  return threadDispatcher
@@ -1,5 +1,5 @@
1
- import { executeWithTimeout, ensureLoggableError, kTimeout } from '@platformatic/foundation'
2
- import { ITC, parseRequest, generateRequest, generateResponse, sanitize, errors } from '@platformatic/itc'
1
+ import { ensureLoggableError, executeWithTimeout, kTimeout } from '@platformatic/foundation'
2
+ import { errors, generateRequest, generateResponse, ITC, parseRequest, sanitize } from '@platformatic/itc'
3
3
  import { MessagingError } from '../errors.js'
4
4
  import { RoundRobinMap } from './round-robin-map.js'
5
5
  import { kITC, kWorkersBroadcast } from './symbols.js'
@@ -7,6 +7,7 @@ import { kITC, kWorkersBroadcast } from './symbols.js'
7
7
  const kPendingResponses = Symbol('plt.messaging.pendingResponses')
8
8
 
9
9
  export class MessagingITC extends ITC {
10
+ #id
10
11
  #timeout
11
12
  #listener
12
13
  #closeResolvers
@@ -22,6 +23,7 @@ export class MessagingITC extends ITC {
22
23
  name: `${id}-messaging`
23
24
  })
24
25
 
26
+ this.#id = id
25
27
  this.#timeout = runtimeConfig.messagingTimeout
26
28
  this.#workers = new RoundRobinMap()
27
29
  this.#sources = new Set()
@@ -67,7 +69,11 @@ export class MessagingITC extends ITC {
67
69
  // Use twice the value here as a fallback measure. The target handler in the main thread is forwarding
68
70
  // the request to the worker, using executeWithTimeout with the user set timeout value.
69
71
  const channel = await executeWithTimeout(
70
- globalThis[kITC].send('getWorkerMessagingChannel', { application: worker.application, worker: worker.worker }),
72
+ globalThis[kITC].send('getWorkerMessagingChannel', {
73
+ id: this.#id,
74
+ application: worker.application,
75
+ worker: worker.worker
76
+ }),
71
77
  this.#timeout * 2
72
78
  )
73
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -35,14 +35,14 @@
35
35
  "typescript": "^5.5.4",
36
36
  "undici-oidc-interceptor": "^0.5.0",
37
37
  "why-is-node-running": "^2.2.2",
38
- "@platformatic/composer": "3.11.0",
39
- "@platformatic/db": "3.11.0",
40
- "@platformatic/gateway": "3.11.0",
41
- "@platformatic/node": "3.11.0",
42
- "@platformatic/service": "3.11.0",
43
- "@platformatic/sql-mapper": "3.11.0",
44
- "@platformatic/wattpm-pprof-capture": "3.11.0",
45
- "@platformatic/sql-graphql": "3.11.0"
38
+ "@platformatic/gateway": "3.13.0",
39
+ "@platformatic/db": "3.13.0",
40
+ "@platformatic/node": "3.13.0",
41
+ "@platformatic/composer": "3.13.0",
42
+ "@platformatic/service": "3.13.0",
43
+ "@platformatic/sql-graphql": "3.13.0",
44
+ "@platformatic/sql-mapper": "3.13.0",
45
+ "@platformatic/wattpm-pprof-capture": "3.13.0"
46
46
  },
47
47
  "dependencies": {
48
48
  "@fastify/accepts": "^5.0.0",
@@ -71,14 +71,14 @@
71
71
  "sonic-boom": "^4.2.0",
72
72
  "systeminformation": "^5.27.11",
73
73
  "undici": "^7.0.0",
74
- "undici-thread-interceptor": "^0.14.0",
74
+ "undici-thread-interceptor": "^0.15.0",
75
75
  "ws": "^8.16.0",
76
- "@platformatic/basic": "3.11.0",
77
- "@platformatic/foundation": "3.11.0",
78
- "@platformatic/itc": "3.11.0",
79
- "@platformatic/generators": "3.11.0",
80
- "@platformatic/metrics": "3.11.0",
81
- "@platformatic/telemetry": "3.11.0"
76
+ "@platformatic/basic": "3.13.0",
77
+ "@platformatic/foundation": "3.13.0",
78
+ "@platformatic/generators": "3.13.0",
79
+ "@platformatic/itc": "3.13.0",
80
+ "@platformatic/metrics": "3.13.0",
81
+ "@platformatic/telemetry": "3.13.0"
82
82
  },
83
83
  "engines": {
84
84
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.11.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.13.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -2183,6 +2183,34 @@
2183
2183
  "callbackUrl"
2184
2184
  ]
2185
2185
  }
2186
+ },
2187
+ "policies": {
2188
+ "type": "object",
2189
+ "properties": {
2190
+ "deny": {
2191
+ "type": "object",
2192
+ "patternProperties": {
2193
+ "^.*$": {
2194
+ "oneOf": [
2195
+ {
2196
+ "type": "string"
2197
+ },
2198
+ {
2199
+ "type": "array",
2200
+ "items": {
2201
+ "type": "string"
2202
+ },
2203
+ "minItems": 1
2204
+ }
2205
+ ]
2206
+ }
2207
+ }
2208
+ }
2209
+ },
2210
+ "required": [
2211
+ "deny"
2212
+ ],
2213
+ "additionalProperties": false
2186
2214
  }
2187
2215
  },
2188
2216
  "anyOf": [