@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/config.d.ts CHANGED
@@ -696,6 +696,10 @@ export type PlatformaticRuntimeConfig = {
696
696
  [k: string]: string;
697
697
  };
698
698
  envfile?: string;
699
+ /**
700
+ * When set to true, the configuration loading fails if a {PLT_*} placeholder references an environment variable which is not set. When set to "warn", a warning listing the missing variables is logged but the placeholders are still replaced with an empty string. Defaults to false.
701
+ */
702
+ strictEnv?: boolean | string;
699
703
  sourceMaps?: boolean;
700
704
  nodeModulesSourceMaps?: string[];
701
705
  scheduler?: {
package/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { FastifyError } from '@fastify/error'
2
2
  import { Configuration, ConfigurationOptions, logFatalError, parseArgs } from '@platformatic/foundation'
3
3
  import { BaseGenerator } from '@platformatic/generators'
4
4
  import { PlatformaticGlobal } from '@platformatic/globals'
5
+ import * as PromClient from '@platformatic/prom-client'
5
6
  import { JSONSchemaType } from 'ajv'
6
7
  import * as colorette from 'colorette'
7
8
  import { EventEmitter } from 'node:events'
@@ -57,9 +58,20 @@ export namespace errors {
57
58
  export const InvalidExtensionError: (path: string) => FastifyError
58
59
  export const ReservedITCHandlerNameError: (name: string) => FastifyError
59
60
  export const DuplicateITCHandlerNameError: (name: string) => FastifyError
61
+ export const MetricFamilyCollisionError: (
62
+ extension: string,
63
+ metricFamily: string,
64
+ otherSource: string
65
+ ) => FastifyError
66
+ export const DuplicateExtensionHealthCheckError: (kind: string, name: string, extension: string) => FastifyError
67
+ export const DuplicateExtensionHealthRouteError: (
68
+ extension: string,
69
+ method: string,
70
+ url: string,
71
+ error: string
72
+ ) => FastifyError
73
+ export const ExtensionHealthRoutesUnavailableError: () => FastifyError
60
74
  export const LastProfileTimeoutError: (id: string) => FastifyError
61
- export const SchedulerJobNotFoundError: (name: string) => FastifyError
62
- export const DuplicateSchedulerJobError: (name: string) => FastifyError
63
75
  }
64
76
 
65
77
  export namespace symbols {
@@ -111,43 +123,26 @@ export interface ApplicationDetails {
111
123
  url?: string | null
112
124
  }
113
125
 
114
- export interface SchedulerJobBase {
115
- name: string
116
- cron: string
117
- paused: boolean
118
- maxRetries: number
119
- lastExecutedAt?: string | null
120
- lastStatus?: 'success' | 'failed' | null
121
- nextRunAt?: string | null
126
+ export interface ApplicationsTopology {
127
+ entrypoint: string
128
+ production: boolean
129
+ applications: ApplicationDetails[]
122
130
  }
123
131
 
124
- export interface ConfiguredSchedulerJob extends SchedulerJobBase {
125
- source: 'config'
126
- callbackUrl: string
127
- method: string
128
- headers?: Record<string, string>
129
- body?: string | Record<string, unknown>
132
+ export interface WorkerDetails {
133
+ application: string
134
+ worker: string
135
+ status: string
136
+ thread: number
137
+ raw?: unknown
130
138
  }
131
139
 
132
- export interface ApplicationSchedulerJob extends SchedulerJobBase {
133
- source: 'application'
134
- applicationId: string
135
- scheduleId: string
136
- tasks: string[]
140
+ export interface SharedContextUpdateOptions {
141
+ overwrite?: boolean
137
142
  }
138
143
 
139
- export type SchedulerJob = ConfiguredSchedulerJob | ApplicationSchedulerJob
140
-
141
- export interface SchedulerRunResult {
142
- name: string
143
- success: boolean
144
- executedAt: string
145
- }
146
-
147
- export interface ApplicationSchedule {
148
- id: string
149
- cron: string
150
- tasks: string[]
144
+ export interface RuntimeSharedContextUpdateOptions extends SharedContextUpdateOptions {
145
+ context?: object
151
146
  }
152
147
 
153
148
  export interface RuntimeMetadata {
@@ -172,18 +167,14 @@ export declare class ManagementClient {
172
167
  getRuntimeConfig (): Promise<Record<string, unknown>>
173
168
  getRuntimeEnv (): Promise<Record<string, string>>
174
169
  getApplicationsIds (): Promise<string[]>
175
- getApplications (): Promise<{ entrypoint: string; production: boolean; applications: ApplicationDetails[] }>
176
- getWorkers (): Promise<Record<string, unknown>>
170
+ getApplications (): Promise<ApplicationsTopology>
171
+ getWorkers (): Promise<Record<string, WorkerDetails>>
177
172
  getApplicationDetails (id: string): Promise<ApplicationDetails>
178
173
  getApplicationConfig (id: string): Promise<Record<string, unknown>>
179
174
  getApplicationEnv (id: string): Promise<Record<string, string>>
180
175
  getApplicationOpenapiSchema (id: string): Promise<unknown>
181
176
  getApplicationGraphqlSchema (id: string): Promise<unknown>
182
177
  getMetrics (format?: string): Promise<{ metrics: unknown }>
183
- getScheduler (): Promise<SchedulerJob[]>
184
- pauseSchedulerJob (name: string): Promise<SchedulerJob>
185
- resumeSchedulerJob (name: string): Promise<SchedulerJob>
186
- runSchedulerJob (name: string): Promise<SchedulerRunResult>
187
178
  startApplication (id: string): Promise<void>
188
179
  stopApplication (id: string): Promise<void>
189
180
  restartApplication (id: string): Promise<void>
@@ -205,15 +196,78 @@ export interface RuntimeExtensionITC {
205
196
  notify (target: string, name: string, payload?: unknown): Promise<void>
206
197
  }
207
198
 
199
+ /**
200
+ * Shared context facade exposed to main-thread runtime extensions.
201
+ *
202
+ * Mirrors the worker-side `sharedContext` API from `@platformatic/globals`:
203
+ * - `get()` returns the current snapshot (a plain object). On the main thread
204
+ * this is always synchronous; the `Promise` variant exists for parity with
205
+ * workers where the first read may be asynchronous.
206
+ * - `update(update, options?)` merges `update` into the current context by
207
+ * default. Pass `{ overwrite: true }` to replace it. Updates are broadcast
208
+ * to all running workers exactly like worker-originated updates.
209
+ */
210
+ export interface RuntimeExtensionSharedContext {
211
+ get (): object | Promise<object>
212
+ update (update: object, options?: SharedContextUpdateOptions): Promise<void>
213
+ }
214
+
215
+ export interface RuntimeExtensionMetrics {
216
+ client: typeof PromClient
217
+ registry: PromClient.Registry
218
+ }
219
+
220
+ export type ExtensionHealthCheckResult =
221
+ | boolean
222
+ | {
223
+ status: boolean
224
+ statusCode?: number
225
+ body?: string | object
226
+ }
227
+
228
+ export type ExtensionHealthCheck = () => ExtensionHealthCheckResult | Promise<ExtensionHealthCheckResult>
229
+
230
+ export interface RuntimeExtensionHealth {
231
+ /**
232
+ * Registers a readiness check that participates in `/ready`.
233
+ * Readiness-only failures do not fail `/status` (liveness).
234
+ * Returns an unregister function.
235
+ */
236
+ registerReadinessCheck (name: string, check: ExtensionHealthCheck): () => void
237
+ /**
238
+ * Registers a liveness check that participates in `/status`.
239
+ * Returns an unregister function.
240
+ */
241
+ registerLivenessCheck (name: string, check: ExtensionHealthCheck): () => void
242
+ /**
243
+ * Registers a Fastify plugin on the health probes server (shared with metrics
244
+ * when they use the same address). Routes are registered before the server
245
+ * starts listening. Returns an unregister function that disables the routes.
246
+ */
247
+ registerRoutes (plugin: (instance: any, opts: any) => unknown | Promise<unknown>): () => void
248
+ }
249
+
208
250
  export interface RuntimeExtensionContext {
209
251
  runtime: Runtime
210
252
  itc: RuntimeExtensionITC
211
253
  logger: Logger
212
254
  options: Record<string, unknown>
213
255
  root: string
256
+ sharedContext: RuntimeExtensionSharedContext
257
+ /**
258
+ * Per-extension Prometheus client and registry. Metrics registered here are
259
+ * collected once by `Runtime.getMetrics()`, the management metrics API, and
260
+ * the existing `/metrics` endpoint. Runtime does not invent a worker ID or
261
+ * application ID for these main-thread metrics; only configured static
262
+ * `metrics.labels` (excluding the application label name) are applied.
263
+ */
264
+ metrics: RuntimeExtensionMetrics
265
+ health: RuntimeExtensionHealth
214
266
  }
215
267
 
216
268
  export interface RuntimeExtensionInstance {
269
+ start?: () => void | Promise<void>
270
+ stop?: () => void | Promise<void>
217
271
  close?: () => void | Promise<void>
218
272
  }
219
273
 
@@ -234,7 +288,52 @@ export declare class Runtime extends EventEmitter {
234
288
  getRuntimeEnv (): Record<string, string>
235
289
  getRuntimeConfig (includeMeta?: boolean): Record<string, unknown>
236
290
  getApplicationsIds (): string[]
291
+ /**
292
+ * Returns topology for every configured application.
293
+ * When `allowUnloaded` is `true`, applications without running workers are
294
+ * reported as `{ id, status: 'stopped' }` instead of throwing.
295
+ */
296
+ getApplications (allowUnloaded?: boolean): Promise<ApplicationsTopology>
297
+ getWorkers (includeRaw?: boolean): Promise<Record<string, WorkerDetails>>
298
+ /**
299
+ * Returns details for a single application.
300
+ * When `allowUnloaded` is `true` and the application has no running workers,
301
+ * returns `{ id, status: 'stopped' }` instead of throwing.
302
+ */
237
303
  getApplicationDetails (id: string, allowUnloaded?: boolean): Promise<ApplicationDetails>
304
+ /**
305
+ * Returns the resolved configuration of an application worker.
306
+ * When `ensureStarted` is `true` (default), throws `PLT_RUNTIME_APPLICATION_NOT_STARTED`
307
+ * if the application is not running. When no worker exists for a known application,
308
+ * throws `PLT_RUNTIME_WORKER_NOT_FOUND`. Unknown applications throw
309
+ * `PLT_RUNTIME_APPLICATION_NOT_FOUND`.
310
+ */
311
+ getApplicationConfig (id: string, ensureStarted?: boolean): Promise<Record<string, unknown>>
312
+ /**
313
+ * Returns the effective environment of an application worker (`process.env`
314
+ * merged with the capability env).
315
+ * When `ensureStarted` is `true` (default), throws `PLT_RUNTIME_APPLICATION_NOT_STARTED`
316
+ * if the application is not running. When no worker exists for a known application
317
+ * (stopped/unloaded), throws `PLT_RUNTIME_WORKER_NOT_FOUND`. Unknown applications
318
+ * throw `PLT_RUNTIME_APPLICATION_NOT_FOUND`.
319
+ */
320
+ getApplicationEnv (id: string, ensureStarted?: boolean): Promise<Record<string, string>>
321
+ getApplicationOpenapiSchema (id: string): Promise<unknown>
322
+ getApplicationGraphqlSchema (id: string): Promise<unknown>
323
+ getMetrics (format?: string): Promise<{ metrics: unknown }>
324
+ /**
325
+ * Returns the current shared context. Synchronous on the main thread.
326
+ * Do not mutate it in place — use `updateSharedContext()` so changes are
327
+ * broadcast to workers.
328
+ */
329
+ getSharedContext (): object
330
+ /**
331
+ * Merges `options.context` into the shared context (or replaces it when
332
+ * `options.overwrite` is `true`) and broadcasts the result to every running
333
+ * worker. Returns the updated snapshot. Broadcast failures are logged and do
334
+ * not reject the promise.
335
+ */
336
+ updateSharedContext (options?: RuntimeSharedContextUpdateOptions): Promise<object>
238
337
  startApplication (id: string, silent?: boolean): Promise<void>
239
338
  stopApplication (id: string, silent?: boolean): Promise<void>
240
339
  restartApplication (id: string): Promise<void>
@@ -243,12 +342,6 @@ export declare class Runtime extends EventEmitter {
243
342
  startApplicationProfiling (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<void>
244
343
  stopApplicationProfiling (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<Buffer>
245
344
  getApplicationLastProfile (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<{ profile: Buffer, timestamp: number | null, preserved: boolean }>
246
- getScheduler (): SchedulerJob[]
247
- pauseSchedulerJob (name: string): Promise<SchedulerJob>
248
- resumeSchedulerJob (name: string): Promise<SchedulerJob>
249
- runSchedulerJob (name: string): Promise<SchedulerRunResult>
250
- getApplicationScheduledTasks (id: string): Promise<ApplicationSchedule[]>
251
- runApplicationScheduledTasks (id: string, scheduleId: string, scheduledTime: number): Promise<unknown>
252
345
  }
253
346
 
254
347
  export function wrapInRuntimeConfig (
package/lib/errors.js CHANGED
@@ -140,17 +140,6 @@ export const MessagingError = createError(
140
140
  'Cannot send a message to application "%s": %s'
141
141
  )
142
142
 
143
- export const SchedulerJobNotFoundError = createError(
144
- `${ERROR_PREFIX}_SCHEDULER_JOB_NOT_FOUND`,
145
- 'Scheduler "%s" not found',
146
- 404
147
- )
148
-
149
- export const DuplicateSchedulerJobError = createError(
150
- `${ERROR_PREFIX}_DUPLICATE_SCHEDULER_JOB`,
151
- 'Scheduler "%s" is already registered'
152
- )
153
-
154
143
  export const MissingPprofCapture = createError(
155
144
  `${ERROR_PREFIX}_MISSING_PPROF_CAPTURE`,
156
145
  'Please install @platformatic/wattpm-pprof-capture'
@@ -166,6 +155,16 @@ export const FailedToLoadExtensionError = createError(
166
155
  'Failed to load the extension "%s": %s'
167
156
  )
168
157
 
158
+ export const FailedToStartExtensionError = createError(
159
+ `${ERROR_PREFIX}_FAILED_TO_START_EXTENSION`,
160
+ 'Failed to start the extension "%s": %s'
161
+ )
162
+
163
+ export const FailedToStopExtensionError = createError(
164
+ `${ERROR_PREFIX}_FAILED_TO_STOP_EXTENSION`,
165
+ 'Failed to stop the extension "%s": %s'
166
+ )
167
+
169
168
  export const InvalidExtensionError = createError(
170
169
  `${ERROR_PREFIX}_INVALID_EXTENSION`,
171
170
  'The extension "%s" must export a setup function as its default export'
@@ -181,6 +180,26 @@ export const DuplicateITCHandlerNameError = createError(
181
180
  'The ITC command "%s" has already been registered'
182
181
  )
183
182
 
183
+ export const MetricFamilyCollisionError = createError(
184
+ `${ERROR_PREFIX}_METRIC_FAMILY_COLLISION`,
185
+ 'Extension "%s" registered metric family "%s" which collides with %s'
186
+ )
187
+
188
+ export const DuplicateExtensionHealthCheckError = createError(
189
+ `${ERROR_PREFIX}_DUPLICATE_EXTENSION_HEALTH_CHECK`,
190
+ 'The extension health %s check "%s" has already been registered by "%s"'
191
+ )
192
+
193
+ export const DuplicateExtensionHealthRouteError = createError(
194
+ `${ERROR_PREFIX}_DUPLICATE_EXTENSION_HEALTH_ROUTE`,
195
+ 'The extension "%s" failed to register health route %s %s: %s'
196
+ )
197
+
198
+ export const ExtensionHealthRoutesUnavailableError = createError(
199
+ `${ERROR_PREFIX}_EXTENSION_HEALTH_ROUTES_UNAVAILABLE`,
200
+ 'Extensions registered health routes but no health probes server is available'
201
+ )
202
+
184
203
  export const FailedToSendHealthSignalsError = createError(
185
204
  `${ERROR_PREFIX}_FAILED_TO_SEND_HEALTH_SIGNALS`,
186
205
  'Cannot send health signals from application "%s": %s'
@@ -79,23 +79,6 @@ export async function managementApiPlugin (app, opts) {
79
79
  await runtime.restart(applications)
80
80
  })
81
81
 
82
- app.get('/scheduler', async () => {
83
- const jobs = await runtime.getScheduler()
84
- return { jobs }
85
- })
86
-
87
- app.post('/scheduler/:name/pause', async request => {
88
- return runtime.pauseSchedulerJob(request.params.name)
89
- })
90
-
91
- app.post('/scheduler/:name/resume', async request => {
92
- return runtime.resumeSchedulerJob(request.params.name)
93
- })
94
-
95
- app.post('/scheduler/:name/run', async request => {
96
- return runtime.runSchedulerJob(request.params.name)
97
- })
98
-
99
82
  app.get('/applications', async () => {
100
83
  return runtime.getApplications()
101
84
  })
@@ -19,10 +19,6 @@ const allOperations = [
19
19
  'getApplicationOpenapiSchema',
20
20
  'getApplicationGraphqlSchema',
21
21
  'getMetrics',
22
- 'getScheduler',
23
- 'pauseSchedulerJob',
24
- 'resumeSchedulerJob',
25
- 'runSchedulerJob',
26
22
  'startApplication',
27
23
  'stopApplication',
28
24
  'restartApplication',
@@ -100,10 +96,6 @@ export function createManagementHandlers (runtime, allowedOperations) {
100
96
  return runtime.getMetrics(format)
101
97
  })
102
98
 
103
- register('getScheduler', async () => {
104
- return runtime.getScheduler()
105
- })
106
-
107
99
  // Write operations
108
100
  register('startApplication', async ({ id }) => {
109
101
  await runtime.startApplication(id)
@@ -121,18 +113,6 @@ export function createManagementHandlers (runtime, allowedOperations) {
121
113
  return runtime.restart(applications)
122
114
  })
123
115
 
124
- register('pauseSchedulerJob', async ({ name }) => {
125
- return runtime.pauseSchedulerJob(name)
126
- })
127
-
128
- register('resumeSchedulerJob', async ({ name }) => {
129
- return runtime.resumeSchedulerJob(name)
130
- })
131
-
132
- register('runSchedulerJob', async ({ name }) => {
133
- return runtime.runSchedulerJob(name)
134
- })
135
-
136
116
  register('addApplications', async ({ applications, start }) => {
137
117
  const config = runtime.getRuntimeConfig(true)
138
118
 
@@ -4,6 +4,7 @@ import { loadModule, sanitizeHTTPSOptions } from '@platformatic/foundation'
4
4
  import fastify from 'fastify'
5
5
  import { createRequire } from 'node:module'
6
6
  import { resolve } from 'node:path'
7
+ import { DuplicateExtensionHealthRouteError } from './errors.js'
7
8
 
8
9
  const DEFAULT_HOSTNAME = '0.0.0.0'
9
10
  const DEFAULT_PORT = 9090
@@ -19,7 +20,7 @@ const DEFAULT_LIVENESS_SUCCESS_BODY = 'OK'
19
20
  const DEFAULT_LIVENESS_FAIL_STATUS_CODE = 500
20
21
  const DEFAULT_LIVENESS_FAIL_BODY = 'ERR'
21
22
 
22
- async function checkReadiness (runtime) {
23
+ async function checkWorkerReadiness (runtime) {
23
24
  const workers = await runtime.getWorkers()
24
25
  const applications = await runtime.getApplicationsIds()
25
26
 
@@ -52,18 +53,39 @@ async function checkReadiness (runtime) {
52
53
  return { response, status }
53
54
  }
54
55
 
56
+ async function checkReadiness (runtime) {
57
+ const workerResult = await checkWorkerReadiness(runtime)
58
+ if (!workerResult.status) {
59
+ return workerResult
60
+ }
61
+
62
+ // Extension readiness checks participate in /ready only.
63
+ const extensionResult = await runtime.runExtensionReadinessChecks()
64
+ if (!extensionResult.status) {
65
+ return {
66
+ status: false,
67
+ response: extensionResult.response ?? workerResult.response
68
+ }
69
+ }
70
+
71
+ return {
72
+ status: true,
73
+ response: extensionResult.response ?? workerResult.response
74
+ }
75
+ }
76
+
55
77
  async function checkLiveness (runtime) {
56
- const { status: ready, response: readinessResponse } = await checkReadiness(runtime)
57
- if (!ready) {
58
- return { status: false, readiness: readinessResponse }
78
+ // Worker readiness still gates liveness (existing semantics), but extension
79
+ // readiness-only failures must not fail /status and cause restart loops.
80
+ const workerReadiness = await checkWorkerReadiness(runtime)
81
+ if (!workerReadiness.status) {
82
+ return { status: false, readiness: workerReadiness.response }
59
83
  }
60
- // TODO test, doc
61
- // in case of readiness check failure, if custom readiness response is set, we return the readiness check response on health check endpoint
62
84
 
63
85
  const checks = await runtime.getCustomHealthChecks()
64
86
 
65
87
  let response
66
- const status = Object.values(checks).every(check => {
88
+ const workerStatus = Object.values(checks).every(check => {
67
89
  if (typeof check === 'boolean') {
68
90
  return check
69
91
  } else if (typeof check === 'object') {
@@ -73,7 +95,22 @@ async function checkLiveness (runtime) {
73
95
  return false
74
96
  })
75
97
 
76
- return { response, status }
98
+ if (!workerStatus) {
99
+ return { response, status: false }
100
+ }
101
+
102
+ const extensionResult = await runtime.runExtensionLivenessChecks()
103
+ if (!extensionResult.status) {
104
+ return {
105
+ status: false,
106
+ response: extensionResult.response ?? response
107
+ }
108
+ }
109
+
110
+ return {
111
+ status: true,
112
+ response: extensionResult.response ?? response
113
+ }
77
114
  }
78
115
 
79
116
  function isHealthProbesServerEnabled (healthProbes) {
@@ -122,6 +159,37 @@ function resolveHealthProbesOptions (metricsOpts, healthProbes) {
122
159
  }
123
160
  }
124
161
 
162
+ async function registerExtensionHealthRoutes (promServer, runtime) {
163
+ const routes = runtime.getExtensionHealthRoutes?.() ?? []
164
+
165
+ for (const entry of routes) {
166
+ if (!entry.active) {
167
+ continue
168
+ }
169
+
170
+ try {
171
+ await promServer.register(async function extensionHealthRoutes (app) {
172
+ app.addHook('onRequest', async (_req, reply) => {
173
+ if (!entry.active) {
174
+ return reply.code(404).send({ message: 'Not Found' })
175
+ }
176
+ })
177
+
178
+ await app.register(entry.plugin)
179
+ })
180
+ entry.applied = true
181
+ } catch (err) {
182
+ const routeMatch = typeof err.message === 'string'
183
+ ? err.message.match(/Method '([^']+)' already declared for route '([^']+)'/i)
184
+ : null
185
+ const method = routeMatch?.[1] ?? 'UNKNOWN'
186
+ const url = routeMatch?.[2] ?? 'unknown'
187
+
188
+ throw new DuplicateExtensionHealthRouteError(entry.extensionPath, method, url, err.message, { cause: err })
189
+ }
190
+ }
191
+ }
192
+
125
193
  async function startServer (runtime, opts, metricsEnabled, healthProbesEnabled) {
126
194
  opts = typeof opts === 'object' && opts !== null ? opts : {}
127
195
 
@@ -281,6 +349,11 @@ async function startServer (runtime, opts, metricsEnabled, healthProbesEnabled)
281
349
  await promServer.register(plugin)
282
350
  }
283
351
 
352
+ // Extension routes must be registered before the server starts listening.
353
+ if (healthProbesEnabled) {
354
+ await registerExtensionHealthRoutes(promServer, runtime)
355
+ }
356
+
284
357
  await promServer.listen({ port, host })
285
358
  return promServer
286
359
  }