@platformatic/runtime 3.63.0-alpha.3 → 3.63.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/lib/scheduler.js CHANGED
@@ -1,176 +1,68 @@
1
1
  import { CronJob, validateCronExpression } from 'cron'
2
2
  import { setTimeout } from 'node:timers/promises'
3
3
  import { request } from 'undici'
4
- import { DuplicateSchedulerJobError, SchedulerJobNotFoundError } from './errors.js'
5
4
 
6
5
  export class SchedulerService {
7
6
  constructor (schedulerConfig, dispatcher, logger) {
8
7
  this.logger = logger
8
+ this.jobsConfig = []
9
+ this.cronJobs = []
9
10
  this.dispatcher = dispatcher
10
- this.jobs = new Map()
11
- this.started = false
12
-
13
- for (const config of schedulerConfig) {
14
- this.addJob({ ...config, source: 'config' })
15
- }
16
- }
17
-
18
- addJob (config, executor) {
19
- // Skip disabled schedulers
20
- if (config.enabled === false) {
21
- return null
22
- }
23
-
24
- // Validate cron expression
25
- const validation = validateCronExpression(config.cron)
26
- if (!validation.valid) {
27
- throw new Error(`Invalid cron expression "${config.cron}" for scheduler "${config.name}"`)
28
- }
29
-
30
- // Set defaults for optional fields
31
- const job = {
32
- ...config,
33
- headers: config.headers || {},
34
- body: config.body || {},
35
- maxRetries: config.maxRetries ?? 3,
36
- source: config.source ?? 'application',
37
- paused: false,
38
- lastExecutedAt: null,
39
- lastStatus: null,
40
- cronJob: null,
41
- executor
42
- }
43
-
44
- if (this.jobs.has(job.name)) {
45
- throw new DuplicateSchedulerJobError(job.name)
46
- }
47
-
48
- this.jobs.set(job.name, job)
49
-
50
- if (this.started) {
51
- this.#startJob(job)
52
- }
53
-
54
- return this.#serializeJob(job)
55
- }
56
-
57
- start () {
58
- this.started = true
59
-
60
- for (const job of this.jobs.values()) {
61
- this.#startJob(job)
62
- }
63
- }
64
-
65
- async stop () {
66
- this.started = false
67
-
68
- for (const job of this.jobs.values()) {
69
- await this.#stopJob(job)
70
- }
71
- }
72
-
73
- getJobs () {
74
- return Array.from(this.jobs.values(), job => this.#serializeJob(job))
75
- }
76
-
77
- // Stops the local cron trigger for a job without removing its configuration.
78
- // Used when an external coordinator (e.g. ICC) takes over the scheduling, so
79
- // that multiple instances of the same runtime do not execute the job concurrently.
80
- async pauseJob (name) {
81
- const job = this.#getJobOrThrow(name)
82
-
83
- if (job.paused) {
84
- return this.#serializeJob(job)
85
- }
86
-
87
- job.paused = true
88
- await this.#stopJob(job)
89
- this.logger.info(`Scheduler "${name}" paused`)
90
- return this.#serializeJob(job)
91
- }
92
-
93
- async resumeJob (name) {
94
- const job = this.#getJobOrThrow(name)
95
-
96
- if (!job.paused) {
97
- return this.#serializeJob(job)
98
- }
99
-
100
- job.paused = false
101
-
102
- if (this.started) {
103
- this.#startJob(job)
104
- }
105
-
106
- this.logger.info(`Scheduler "${name}" resumed`)
107
- return this.#serializeJob(job)
11
+ this.validateCronSchedulers(schedulerConfig)
108
12
  }
109
13
 
110
- async removeApplicationJobs (applicationId) {
111
- for (const [name, job] of this.jobs) {
112
- if (job.applicationId === applicationId) {
113
- await this.#stopJob(job)
114
- this.jobs.delete(name)
14
+ validateCronSchedulers (schedulerConfig) {
15
+ for (const config of schedulerConfig) {
16
+ // Skip disabled schedulers
17
+ if (config.enabled === false) {
18
+ continue
115
19
  }
116
- }
117
- }
118
20
 
119
- async stopApplicationJobs (applicationId) {
120
- for (const job of this.jobs.values()) {
121
- if (job.applicationId === applicationId) {
122
- await this.#stopJob(job)
21
+ // Validate cron expression
22
+ const validation = validateCronExpression(config.cron)
23
+ if (!validation.valid) {
24
+ throw new Error(`Invalid cron expression "${config.cron}" for scheduler "${config.name}"`)
123
25
  }
124
- }
125
- }
126
-
127
- // Executes a job immediately, regardless of its cron schedule or paused state.
128
- // Used by an external coordinator to trigger the execution in this instance.
129
- async runJob (name) {
130
- const job = this.#getJobOrThrow(name)
131
- return this.executeCallback(job)
132
- }
133
26
 
134
- #getJobOrThrow (name) {
135
- const job = this.jobs.get(name)
136
- if (!job) {
137
- throw new SchedulerJobNotFoundError(name)
27
+ // Set defaults for optional fields
28
+ const job = {
29
+ ...config,
30
+ headers: config.headers || {},
31
+ body: config.body || {},
32
+ maxRetries: config.maxRetries || 3
33
+ }
34
+ this.jobsConfig.push(job)
138
35
  }
139
- return job
140
36
  }
141
37
 
142
- #startJob (job) {
143
- if (job.cronJob || job.paused) {
144
- return
38
+ start () {
39
+ for (const job of this.jobsConfig) {
40
+ this.logger.info(`Configuring scheduler "${job.name}" with cron "${job.cron}"`)
41
+ const cronJob = CronJob.from({
42
+ cronTime: job.cron,
43
+ onTick: async () => {
44
+ this.logger.info(`Executing scheduler "${job.name}"`)
45
+ // This cannot throw, the try/catch is inside
46
+ await this.executeCallback(job)
47
+ },
48
+ start: true,
49
+ timeZone: 'UTC',
50
+ waitForCompletion: true
51
+ })
52
+
53
+ this.cronJobs.push(cronJob)
145
54
  }
146
-
147
- this.logger.info(`Configuring scheduler "${job.name}" with cron "${job.cron}"`)
148
- job.cronJob = CronJob.from({
149
- cronTime: job.cron,
150
- onTick: async () => {
151
- this.logger.info(`Executing scheduler "${job.name}"`)
152
- // This cannot throw, the try/catch is inside
153
- await this.executeCallback(job)
154
- },
155
- start: true,
156
- timeZone: 'UTC',
157
- waitForCompletion: true
158
- })
159
55
  }
160
56
 
161
- async #stopJob (job) {
162
- if (!job.cronJob) {
163
- return
57
+ async stop () {
58
+ for (const job of this.cronJobs) {
59
+ await job.stop()
164
60
  }
165
-
166
- await job.cronJob.stop()
167
- job.cronJob = null
168
61
  }
169
62
 
170
63
  async executeCallback (scheduler) {
171
64
  let attempt = 0
172
65
  let success = false
173
- const scheduledTime = Date.now()
174
66
 
175
67
  while (!success && attempt < scheduler.maxRetries) {
176
68
  try {
@@ -182,32 +74,29 @@ export class SchedulerService {
182
74
  )
183
75
  await setTimeout(delay)
184
76
  }
185
- if (scheduler.executor) {
186
- await scheduler.executor({ scheduledTime })
187
- } else {
188
- const headers = {
189
- 'x-retry-attempt': attempt + 1,
190
- ...scheduler.headers
191
- }
192
-
193
- const bodyString = typeof scheduler.body === 'string' ? scheduler.body : JSON.stringify(scheduler.body)
194
- const response = await request(scheduler.callbackUrl, {
195
- method: scheduler.method,
196
- headers,
197
- body: bodyString,
198
- dispatcher: this.dispatcher
199
- })
200
-
201
- // Consume the body to release the connection.
202
- await response.body.dump()
203
-
204
- if (response.statusCode < 200 || response.statusCode >= 300) {
205
- throw new Error(`HTTP error ${response.statusCode}`)
206
- }
77
+ const headers = {
78
+ 'x-retry-attempt': attempt + 1,
79
+ ...scheduler.headers
207
80
  }
208
81
 
209
- this.logger.info(`Scheduler "${scheduler.name}" executed successfully`)
210
- success = true
82
+ const bodyString = typeof scheduler.body === 'string' ? scheduler.body : JSON.stringify(scheduler.body)
83
+ const response = await request(scheduler.callbackUrl, {
84
+ method: scheduler.method,
85
+ headers,
86
+ body: bodyString,
87
+ dispatcher: this.dispatcher
88
+ })
89
+
90
+ // Consumes the body, but we are not interested in the body content,
91
+ // we don't save it anywere, so we just dump it
92
+ await response.body.dump()
93
+
94
+ if (response.statusCode >= 200 && response.statusCode < 300) {
95
+ this.logger.info(`Scheduler "${scheduler.name}" executed successfully`)
96
+ success = true
97
+ } else {
98
+ throw new Error(`HTTP error ${response.statusCode}`)
99
+ }
211
100
  } catch (error) {
212
101
  this.logger.error(
213
102
  `Error executing scheduler "${scheduler.name}" (attempt ${attempt + 1}/${scheduler.maxRetries}):`,
@@ -217,30 +106,9 @@ export class SchedulerService {
217
106
  }
218
107
  }
219
108
 
220
- scheduler.lastExecutedAt = new Date().toISOString()
221
- scheduler.lastStatus = success ? 'success' : 'failed'
222
-
223
109
  if (!success) {
224
110
  this.logger.error(`Scheduler "${scheduler.name}" failed after ${scheduler.maxRetries} attempts`)
225
111
  }
226
-
227
- return { name: scheduler.name, success, executedAt: scheduler.lastExecutedAt }
228
- }
229
-
230
- #serializeJob (job) {
231
- const { cronJob, executor, ...serializable } = job
232
- let nextRunAt = null
233
-
234
- if (cronJob) {
235
- try {
236
- nextRunAt = cronJob.nextDate().toJSDate().toISOString()
237
- /* c8 ignore next 3 */
238
- } catch {
239
- nextRunAt = null
240
- }
241
- }
242
-
243
- return { ...serializable, nextRunAt }
244
112
  }
245
113
  }
246
114
 
@@ -95,7 +95,8 @@ export class Controller extends EventEmitter {
95
95
  worker: workerData?.worker,
96
96
  resourceLimits: workerData?.resourceLimits,
97
97
  hasManagementApi: !!runtimeConfig.managementApi,
98
- fetchApplicationUrl: fetchApplicationUrl.bind(null, applicationConfig)
98
+ fetchApplicationUrl: fetchApplicationUrl.bind(null, applicationConfig),
99
+ strictEnv: runtimeConfig.strictEnv
99
100
  }
100
101
  }
101
102
 
@@ -141,7 +142,8 @@ export class Controller extends EventEmitter {
141
142
  if (appConfig.config) {
142
143
  // Parse the configuration file the first time to obtain the schema
143
144
  const unvalidatedConfig = await loadConfiguration(appConfig.config, null, {
144
- onMissingEnv: this.#context.fetchApplicationUrl
145
+ onMissingEnv: this.#context.fetchApplicationUrl,
146
+ strictEnv: this.#context.strictEnv
145
147
  })
146
148
  const pkg = await loadConfigurationModule(appConfig.path, unvalidatedConfig)
147
149
  this.capability = await pkg.create(appConfig.path, appConfig.config, this.#context)
package/lib/worker/itc.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ensureLoggableError, executeInParallel, executeWithTimeout, kTimeout } from '@platformatic/foundation'
2
2
  import { getEvents, getLogger, getMessaging, updateGlobals } from '@platformatic/globals'
3
- import { initializeITCTelemetry, ITC } from '@platformatic/itc'
3
+ import { ITC, initializeITCTelemetry } from '@platformatic/itc'
4
4
  import { Unpromise } from '@watchable/unpromise'
5
5
  import { once } from 'node:events'
6
6
  import { createRequire } from 'node:module'
@@ -112,9 +112,14 @@ async function safeHandleInITC (worker, fn) {
112
112
  }
113
113
 
114
114
  async function closeITC (dispatcher, itc, messaging) {
115
- await dispatcher.interceptor.close()
116
- itc.close()
117
- messaging.close()
115
+ try {
116
+ await dispatcher.interceptor.close()
117
+ itc.close()
118
+ messaging.close()
119
+ } finally {
120
+ const events = getEvents()
121
+ events.emit('exit')
122
+ }
118
123
  }
119
124
 
120
125
  export async function sendViaITC (worker, name, message, transferList) {
@@ -183,7 +188,12 @@ export async function setupITC (controller, application, dispatcher, sharedConte
183
188
 
184
189
  // Reply to the runtime that the start failed, so it can cleanup
185
190
  once(itc, 'application:worker:start:processed').then(() => {
186
- closeITC(dispatcher, itc, messaging).catch(() => {})
191
+ closeITC(dispatcher, itc, messaging).catch(err => {
192
+ logger.error(
193
+ { err: ensureLoggableError(err) },
194
+ 'Failed to close the worker ITC after a failed start.'
195
+ )
196
+ })
187
197
  })
188
198
 
189
199
  throw ensureLoggableError(e)
@@ -195,36 +205,36 @@ export async function setupITC (controller, application, dispatcher, sharedConte
195
205
  }
196
206
 
197
207
  dispatcher.replaceServer(await controller.capability.getDispatchTarget())
198
-
199
- const scheduledTasks =
200
- typeof controller.capability.getScheduledTasks === 'function'
201
- ? await controller.capability.getScheduledTasks()
202
- : []
203
-
204
- return {
205
- url: application.entrypoint ? controller.capability.getUrl() : null,
206
- scheduledTasks
207
- }
208
+ return application.entrypoint ? controller.capability.getUrl() : null
208
209
  },
209
210
 
210
211
  async stop ({ force, dependents }) {
211
- const status = controller.getStatus()
212
+ try {
213
+ const status = controller.getStatus()
212
214
 
213
- if (!force && status === 'starting') {
214
- await once(controller, 'start')
215
- }
215
+ if (!force && status === 'starting') {
216
+ await once(controller, 'start')
217
+ }
216
218
 
217
- if (force || status.startsWith('start')) {
218
- // This gives a chance to a capability to perform custom logic
219
- const events = getEvents()
220
- events.emit('stop')
219
+ if (force || status.startsWith('start')) {
220
+ // This gives a chance to a capability to perform custom logic
221
+ const events = getEvents()
222
+ events.emit('stop')
221
223
 
222
- await controller.stop(force, dependents)
224
+ await controller.stop(force, dependents)
225
+ }
226
+ } finally {
227
+ // Always schedule cleanup, even when stop throws. Otherwise the worker
228
+ // keeps open handles and runtime shutdown hangs until the grace timeout.
229
+ once(itc, 'application:worker:stop:processed').then(() => {
230
+ closeITC(dispatcher, itc, messaging).catch(err => {
231
+ logger.error(
232
+ { err: ensureLoggableError(err) },
233
+ 'Failed to close the worker ITC after stop.'
234
+ )
235
+ })
236
+ })
223
237
  }
224
-
225
- once(itc, 'application:worker:stop:processed').then(() => {
226
- closeITC(dispatcher, itc, messaging).catch(() => {})
227
- })
228
238
  },
229
239
 
230
240
  async getDependencies () {
@@ -295,22 +305,6 @@ export async function setupITC (controller, application, dispatcher, sharedConte
295
305
  }
296
306
  },
297
307
 
298
- async getApplicationScheduledTasks () {
299
- if (typeof controller.capability.getScheduledTasks !== 'function') {
300
- return []
301
- }
302
-
303
- return controller.capability.getScheduledTasks()
304
- },
305
-
306
- async runApplicationScheduledTasks ({ scheduleId, scheduledTime }) {
307
- if (typeof controller.capability.runScheduledTasks !== 'function') {
308
- throw new Error(`Application "${application.id}" does not support scheduled task execution`)
309
- }
310
-
311
- return controller.capability.runScheduledTasks(scheduleId, scheduledTime)
312
- },
313
-
314
308
  async getApplicationMeta () {
315
309
  try {
316
310
  return await controller.capability.getMeta()
@@ -364,6 +364,16 @@ async function main () {
364
364
 
365
365
  function stripBasePath (basePath) {
366
366
  const kBasePath = Symbol('kBasePath')
367
+ const absoluteUrlPattern = /^https?:\/\//i
368
+
369
+ function prependBasePath (value) {
370
+ // Absolute and protocol-relative URLs (RFC 7231 Location) must not be rewritten
371
+ if (typeof value !== 'string' || absoluteUrlPattern.test(value) || value.startsWith('//') || value.startsWith(basePath)) {
372
+ return value
373
+ }
374
+
375
+ return basePath + value
376
+ }
367
377
 
368
378
  subscribe('http.server.request.start', ({ request, response }) => {
369
379
  if (request.url.startsWith(basePath)) {
@@ -389,8 +399,8 @@ function stripBasePath (basePath) {
389
399
 
390
400
  if (headers) {
391
401
  for (const key in headers) {
392
- if (key.toLowerCase() === 'location' && !headers[key].startsWith(basePath)) {
393
- headers[key] = basePath + headers[key]
402
+ if (key.toLowerCase() === 'location') {
403
+ headers[key] = prependBasePath(headers[key])
394
404
  }
395
405
  }
396
406
  }
@@ -401,8 +411,8 @@ function stripBasePath (basePath) {
401
411
 
402
412
  ServerResponse.prototype.setHeader = function (name, value) {
403
413
  if (this[kBasePath]) {
404
- if (name.toLowerCase() === 'location' && !value.startsWith(basePath)) {
405
- value = basePath + value
414
+ if (name.toLowerCase() === 'location') {
415
+ value = prependBasePath(value)
406
416
  }
407
417
  }
408
418
  originSetHeader.call(this, name, value)
@@ -70,22 +70,6 @@ export class ManagementClient {
70
70
  return this.#send('getMetrics', { format })
71
71
  }
72
72
 
73
- getScheduler () {
74
- return this.#send('getScheduler')
75
- }
76
-
77
- pauseSchedulerJob (name) {
78
- return this.#send('pauseSchedulerJob', { name })
79
- }
80
-
81
- resumeSchedulerJob (name) {
82
- return this.#send('resumeSchedulerJob', { name })
83
- }
84
-
85
- runSchedulerJob (name) {
86
- return this.#send('runSchedulerJob', { name })
87
- }
88
-
89
73
  startApplication (id) {
90
74
  return this.#send('startApplication', { id })
91
75
  }
@@ -114,14 +114,21 @@ export class MessagingITC extends ITC {
114
114
  context.meta = { ...options?.meta, ...telemetry?.meta }
115
115
  }
116
116
 
117
- const send = () => executeWithTimeout(super.send(name, message, context), this.#timeout)
118
- const response = telemetry ? await telemetry.run(send) : await send()
119
-
120
- if (response === kTimeout) {
121
- throw new MessagingError(application, 'Timeout while waiting for a response.')
117
+ const abortController = new AbortController()
118
+ const timeout = setTimeout(() => abortController.abort(), this.#timeout).unref()
119
+ context.signal = abortController.signal
120
+
121
+ try {
122
+ const send = () => super.send(name, message, context)
123
+ return telemetry ? await telemetry.run(send) : await send()
124
+ } catch (err) {
125
+ if (abortController.signal.aborted) {
126
+ throw new MessagingError(application, 'Timeout while waiting for a response.')
127
+ }
128
+ throw err
129
+ } finally {
130
+ clearTimeout(timeout)
122
131
  }
123
-
124
- return response
125
132
  } catch (err) {
126
133
  error = err
127
134
  throw err
@@ -181,6 +188,12 @@ export class MessagingITC extends ITC {
181
188
  channel.postMessage(sanitize(request, transferList), transferList)
182
189
  }
183
190
 
191
+ _onRequestSettled (reqId, context) {
192
+ if (context.trackResponse) {
193
+ context.channel[kPendingResponses].delete(reqId)
194
+ }
195
+ }
196
+
184
197
  _createClosePromise () {
185
198
  const { promise, resolve, reject } = Promise.withResolvers()
186
199
  this.#closeResolvers = { resolve, reject }
@@ -121,20 +121,31 @@ export class DynamicWorkersScaler {
121
121
 
122
122
  if (config.minWorkers > 1) {
123
123
  const update = { application: application.id, workers: config.minWorkers }
124
-
125
- if (!this.#status === 'started') {
126
- await this.#runtime.updateApplicationsResources([update])
127
- } else {
128
- this.#initialUpdates.push(update)
129
- }
124
+ this.#initialUpdates.push(update)
130
125
  }
131
126
 
132
127
  this.#appsConfigs[application.id] = config
133
128
  this.#algorithm.addApplication(application.id, config)
134
129
  }
135
130
 
136
- remove (application) {
137
- this.#algorithm.removeApplication(application.id)
131
+ async applyPendingUpdate (applicationId) {
132
+ if (this.#status !== 'started') {
133
+ return
134
+ }
135
+
136
+ const update = this.#initialUpdates.find(update => update.application === applicationId)
137
+ if (!update) {
138
+ return
139
+ }
140
+
141
+ await this.#runtime.updateApplicationsResources([update])
142
+ this.#initialUpdates = this.#initialUpdates.filter(pending => pending !== update)
143
+ }
144
+
145
+ remove (applicationId) {
146
+ delete this.#appsConfigs[applicationId]
147
+ this.#initialUpdates = this.#initialUpdates.filter(update => update.application !== applicationId)
148
+ this.#algorithm.removeApplication(applicationId)
138
149
  }
139
150
 
140
151
  async #chechHealth () {
@@ -175,7 +186,7 @@ export class DynamicWorkersScaler {
175
186
  shouldCheckForScaling = true
176
187
  }
177
188
  } catch (err) {
178
- this.logger.error({ err }, 'Failed to get health for worker')
189
+ this.#runtime.logger.error({ err }, 'Failed to get health for worker')
179
190
  }
180
191
  }
181
192
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.63.0-alpha.3",
3
+ "version": "3.63.0",
4
4
  "description": "Platformatic runtime",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -39,14 +39,14 @@
39
39
  "typescript": "^5.9.3",
40
40
  "undici-oidc-interceptor": "^0.9.0",
41
41
  "why-is-node-running": "^2.2.2",
42
- "@platformatic/composer": "3.63.0-alpha.3",
43
- "@platformatic/db": "3.63.0-alpha.3",
44
- "@platformatic/gateway": "3.63.0-alpha.3",
45
- "@platformatic/node": "3.63.0-alpha.3",
46
- "@platformatic/service": "3.63.0-alpha.3",
47
- "@platformatic/sql-graphql": "3.63.0-alpha.3",
48
- "@platformatic/wattpm-pprof-capture": "3.63.0-alpha.3",
49
- "@platformatic/sql-mapper": "3.63.0-alpha.3"
42
+ "@platformatic/composer": "3.63.0",
43
+ "@platformatic/db": "3.63.0",
44
+ "@platformatic/gateway": "3.63.0",
45
+ "@platformatic/service": "3.63.0",
46
+ "@platformatic/sql-graphql": "3.63.0",
47
+ "@platformatic/sql-mapper": "3.63.0",
48
+ "@platformatic/node": "3.63.0",
49
+ "@platformatic/wattpm-pprof-capture": "3.63.0"
50
50
  },
51
51
  "dependencies": {
52
52
  "@fastify/accepts": "^5.0.0",
@@ -69,7 +69,7 @@
69
69
  "help-me": "^5.0.0",
70
70
  "minimist": "^1.2.8",
71
71
  "pino": "^10.1.0",
72
- "pino-opentelemetry-transport": "^3.0.0",
72
+ "pino-opentelemetry-transport": "^4.0.2",
73
73
  "pino-pretty": "^13.0.0",
74
74
  "semgrator": "^0.3.0",
75
75
  "sonic-boom": "^4.2.0",
@@ -77,13 +77,13 @@
77
77
  "undici": "^7.27.2",
78
78
  "undici-thread-interceptor": "^1.3.1",
79
79
  "ws": "^8.16.0",
80
- "@platformatic/basic": "3.63.0-alpha.3",
81
- "@platformatic/foundation": "3.63.0-alpha.3",
82
- "@platformatic/generators": "3.63.0-alpha.3",
83
- "@platformatic/itc": "3.63.0-alpha.3",
84
- "@platformatic/metrics": "3.63.0-alpha.3",
85
- "@platformatic/globals": "3.63.0-alpha.3",
86
- "@platformatic/telemetry": "3.63.0-alpha.3"
80
+ "@platformatic/basic": "3.63.0",
81
+ "@platformatic/generators": "3.63.0",
82
+ "@platformatic/foundation": "3.63.0",
83
+ "@platformatic/globals": "3.63.0",
84
+ "@platformatic/metrics": "3.63.0",
85
+ "@platformatic/telemetry": "3.63.0",
86
+ "@platformatic/itc": "3.63.0"
87
87
  },
88
88
  "engines": {
89
89
  "node": ">=22.19.0"