@platformatic/runtime 3.63.0-alpha.3 → 3.64.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,13 +2,14 @@ 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'
8
9
  import { Logger } from 'pino'
9
10
  import { PlatformaticRuntimeConfig } from './config.js'
10
11
 
11
- export type RuntimeConfiguration = Promise<Configuration<PlatformaticRuntimeConfig>>
12
+ export type RuntimeConfiguration = Configuration<PlatformaticRuntimeConfig>
12
13
 
13
14
  export type ApplicationCommandContext = {
14
15
  colorette: typeof colorette
@@ -57,6 +58,19 @@ 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
75
  export const SchedulerJobNotFoundError: (name: string) => FastifyError
62
76
  export const DuplicateSchedulerJobError: (name: string) => FastifyError
@@ -111,6 +125,28 @@ export interface ApplicationDetails {
111
125
  url?: string | null
112
126
  }
113
127
 
128
+ export interface ApplicationsTopology {
129
+ entrypoint: string
130
+ production: boolean
131
+ applications: ApplicationDetails[]
132
+ }
133
+
134
+ export interface WorkerDetails {
135
+ application: string
136
+ worker: string
137
+ status: string
138
+ thread: number
139
+ raw?: unknown
140
+ }
141
+
142
+ export interface SharedContextUpdateOptions {
143
+ overwrite?: boolean
144
+ }
145
+
146
+ export interface RuntimeSharedContextUpdateOptions extends SharedContextUpdateOptions {
147
+ context?: object
148
+ }
149
+
114
150
  export interface SchedulerJobBase {
115
151
  name: string
116
152
  cron: string
@@ -172,15 +208,15 @@ export declare class ManagementClient {
172
208
  getRuntimeConfig (): Promise<Record<string, unknown>>
173
209
  getRuntimeEnv (): Promise<Record<string, string>>
174
210
  getApplicationsIds (): Promise<string[]>
175
- getApplications (): Promise<{ entrypoint: string; production: boolean; applications: ApplicationDetails[] }>
176
- getWorkers (): Promise<Record<string, unknown>>
211
+ getApplications (): Promise<ApplicationsTopology>
212
+ getWorkers (): Promise<Record<string, WorkerDetails>>
177
213
  getApplicationDetails (id: string): Promise<ApplicationDetails>
178
214
  getApplicationConfig (id: string): Promise<Record<string, unknown>>
179
215
  getApplicationEnv (id: string): Promise<Record<string, string>>
180
216
  getApplicationOpenapiSchema (id: string): Promise<unknown>
181
217
  getApplicationGraphqlSchema (id: string): Promise<unknown>
182
218
  getMetrics (format?: string): Promise<{ metrics: unknown }>
183
- getScheduler (): Promise<SchedulerJob[]>
219
+ getSchedulerJobs (): Promise<SchedulerJob[]>
184
220
  pauseSchedulerJob (name: string): Promise<SchedulerJob>
185
221
  resumeSchedulerJob (name: string): Promise<SchedulerJob>
186
222
  runSchedulerJob (name: string): Promise<SchedulerRunResult>
@@ -205,15 +241,78 @@ export interface RuntimeExtensionITC {
205
241
  notify (target: string, name: string, payload?: unknown): Promise<void>
206
242
  }
207
243
 
244
+ /**
245
+ * Shared context facade exposed to main-thread runtime extensions.
246
+ *
247
+ * Mirrors the worker-side `sharedContext` API from `@platformatic/globals`:
248
+ * - `get()` returns the current snapshot (a plain object). On the main thread
249
+ * this is always synchronous; the `Promise` variant exists for parity with
250
+ * workers where the first read may be asynchronous.
251
+ * - `update(update, options?)` merges `update` into the current context by
252
+ * default. Pass `{ overwrite: true }` to replace it. Updates are broadcast
253
+ * to all running workers exactly like worker-originated updates.
254
+ */
255
+ export interface RuntimeExtensionSharedContext {
256
+ get (): object | Promise<object>
257
+ update (update: object, options?: SharedContextUpdateOptions): Promise<void>
258
+ }
259
+
260
+ export interface RuntimeExtensionMetrics {
261
+ client: typeof PromClient
262
+ registry: PromClient.Registry
263
+ }
264
+
265
+ export type ExtensionHealthCheckResult =
266
+ | boolean
267
+ | {
268
+ status: boolean
269
+ statusCode?: number
270
+ body?: string | object
271
+ }
272
+
273
+ export type ExtensionHealthCheck = () => ExtensionHealthCheckResult | Promise<ExtensionHealthCheckResult>
274
+
275
+ export interface RuntimeExtensionHealth {
276
+ /**
277
+ * Registers a readiness check that participates in `/ready`.
278
+ * Readiness-only failures do not fail `/status` (liveness).
279
+ * Returns an unregister function.
280
+ */
281
+ registerReadinessCheck (name: string, check: ExtensionHealthCheck): () => void
282
+ /**
283
+ * Registers a liveness check that participates in `/status`.
284
+ * Returns an unregister function.
285
+ */
286
+ registerLivenessCheck (name: string, check: ExtensionHealthCheck): () => void
287
+ /**
288
+ * Registers a Fastify plugin on the health probes server (shared with metrics
289
+ * when they use the same address). Routes are registered before the server
290
+ * starts listening. Returns an unregister function that disables the routes.
291
+ */
292
+ registerRoutes (plugin: (instance: any, opts: any) => unknown | Promise<unknown>): () => void
293
+ }
294
+
208
295
  export interface RuntimeExtensionContext {
209
296
  runtime: Runtime
210
297
  itc: RuntimeExtensionITC
211
298
  logger: Logger
212
299
  options: Record<string, unknown>
213
300
  root: string
301
+ sharedContext: RuntimeExtensionSharedContext
302
+ /**
303
+ * Per-extension Prometheus client and registry. Metrics registered here are
304
+ * collected once by `Runtime.getMetrics()`, the management metrics API, and
305
+ * the existing `/metrics` endpoint. Runtime does not invent a worker ID or
306
+ * application ID for these main-thread metrics; only configured static
307
+ * `metrics.labels` (excluding the application label name) are applied.
308
+ */
309
+ metrics: RuntimeExtensionMetrics
310
+ health: RuntimeExtensionHealth
214
311
  }
215
312
 
216
313
  export interface RuntimeExtensionInstance {
314
+ start?: () => void | Promise<void>
315
+ stop?: () => void | Promise<void>
217
316
  close?: () => void | Promise<void>
218
317
  }
219
318
 
@@ -232,18 +331,76 @@ export declare class Runtime extends EventEmitter {
232
331
  getRuntimeStatus (): string
233
332
  getRuntimeMetadata (): Promise<RuntimeMetadata>
234
333
  getRuntimeEnv (): Record<string, string>
334
+ // `includeMeta: true` returns the full merged Configuration object (which
335
+ // carries the kMetadata symbol key: `{ root, path, env, module }`), not a
336
+ // plain Record; the argument-invariant overload below covers every other
337
+ // call.
338
+ getRuntimeConfig (includeMeta: true): RuntimeConfiguration
235
339
  getRuntimeConfig (includeMeta?: boolean): Record<string, unknown>
236
340
  getApplicationsIds (): string[]
341
+ /**
342
+ * Returns topology for every configured application.
343
+ * When `allowUnloaded` is `true`, applications without running workers are
344
+ * reported as `{ id, status: 'stopped' }` instead of throwing.
345
+ */
346
+ getApplications (allowUnloaded?: boolean): Promise<ApplicationsTopology>
347
+ getWorkers (includeRaw?: boolean): Promise<Record<string, WorkerDetails>>
348
+ /**
349
+ * Returns details for a single application.
350
+ * When `allowUnloaded` is `true` and the application has no running workers,
351
+ * returns `{ id, status: 'stopped' }` instead of throwing.
352
+ */
237
353
  getApplicationDetails (id: string, allowUnloaded?: boolean): Promise<ApplicationDetails>
354
+ /**
355
+ * Returns the resolved configuration of an application worker.
356
+ * When `ensureStarted` is `true` (default), throws `PLT_RUNTIME_APPLICATION_NOT_STARTED`
357
+ * if the application is not running. When no worker exists for a known application,
358
+ * throws `PLT_RUNTIME_WORKER_NOT_FOUND`. Unknown applications throw
359
+ * `PLT_RUNTIME_APPLICATION_NOT_FOUND`.
360
+ */
361
+ getApplicationConfig (id: string, ensureStarted?: boolean): Promise<Record<string, unknown>>
362
+ /**
363
+ * Returns the effective environment of an application worker (`process.env`
364
+ * merged with the capability env).
365
+ * When `ensureStarted` is `true` (default), throws `PLT_RUNTIME_APPLICATION_NOT_STARTED`
366
+ * if the application is not running. When no worker exists for a known application
367
+ * (stopped/unloaded), throws `PLT_RUNTIME_WORKER_NOT_FOUND`. Unknown applications
368
+ * throw `PLT_RUNTIME_APPLICATION_NOT_FOUND`.
369
+ */
370
+ getApplicationEnv (id: string, ensureStarted?: boolean): Promise<Record<string, string>>
371
+ getApplicationOpenapiSchema (id: string): Promise<unknown>
372
+ getApplicationGraphqlSchema (id: string): Promise<unknown>
373
+ getMetrics (format?: string): Promise<{ metrics: unknown }>
374
+ /**
375
+ * Returns the current shared context. Synchronous on the main thread.
376
+ * Do not mutate it in place — use `updateSharedContext()` so changes are
377
+ * broadcast to workers.
378
+ */
379
+ getSharedContext (): object
380
+ /**
381
+ * Merges `options.context` into the shared context (or replaces it when
382
+ * `options.overwrite` is `true`) and broadcasts the result to every running
383
+ * worker. Returns the updated snapshot. Broadcast failures are logged and do
384
+ * not reject the promise.
385
+ */
386
+ updateSharedContext (options?: RuntimeSharedContextUpdateOptions): Promise<object>
238
387
  startApplication (id: string, silent?: boolean): Promise<void>
239
388
  stopApplication (id: string, silent?: boolean): Promise<void>
240
389
  restartApplication (id: string): Promise<void>
241
390
  addApplications (applications: unknown[], start?: boolean): Promise<ApplicationDetails[]>
242
391
  removeApplications (applications: string[], silent?: boolean): Promise<ApplicationDetails[]>
392
+ startApplicationProfiling (id: string, options: Record<string, unknown> & { allWorkers: true }, ensureStarted?: boolean): Promise<{ workers: number[] }>
243
393
  startApplicationProfiling (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<void>
394
+ stopApplicationProfiling (id: string, options: Record<string, unknown> & { allWorkers: true }, ensureStarted?: boolean): Promise<Array<{ workerIndex: number, profile: Buffer }>>
395
+ stopApplicationProfiling (id: string, options: Record<string, unknown> & { includeSampleCount: true }, ensureStarted?: boolean): Promise<{ profile: Buffer, sampleCount: number }>
244
396
  stopApplicationProfiling (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<Buffer>
245
- getApplicationLastProfile (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<{ profile: Buffer, timestamp: number | null, preserved: boolean }>
246
- getScheduler (): SchedulerJob[]
397
+ getApplicationLastProfile (id: string, options?: Record<string, unknown>, ensureStarted?: boolean): Promise<{ profile: Buffer, timestamp: number | null, sampleCount: number | null, preserved: boolean }>
398
+ // Patches one application's resolved configuration through the same
399
+ // per-application JSON-Patch mechanism the runtime already applies during
400
+ // create()/start().
401
+ setApplicationConfigPatch (applicationId: string, patch: Array<Record<string, unknown>>): void
402
+ removeApplicationConfigPatch (applicationId: string): void
403
+ getSchedulerJobs (): SchedulerJob[]
247
404
  pauseSchedulerJob (name: string): Promise<SchedulerJob>
248
405
  resumeSchedulerJob (name: string): Promise<SchedulerJob>
249
406
  runSchedulerJob (name: string): Promise<SchedulerRunResult>
@@ -264,13 +421,27 @@ export declare function loadConfiguration (
264
421
  context?: ConfigurationOptions
265
422
  ): Promise<RuntimeConfiguration>
266
423
 
424
+ // create() additionally accepts `setupSignals`, a Runtime-only option (it is
425
+ // never read outside this package) that controls whether SIGUSR2/close-with-grace
426
+ // handlers are installed.
427
+ export interface RuntimeCreateContext extends ConfigurationOptions<PlatformaticRuntimeConfig> {
428
+ setupSignals?: boolean
429
+ }
430
+
267
431
  export function create (
268
432
  root: string,
269
433
  source?: string | PlatformaticRuntimeConfig,
270
- context?: ConfigurationOptions
434
+ context?: RuntimeCreateContext
271
435
  ): Promise<Runtime>
272
436
 
273
- export declare function prepareApplication (config: RuntimeConfiguration, application: object): object
437
+ // The runtime's actual per-application preparation (worker defaults, paths,
438
+ // telemetry hooks, capability resolution) also takes the resolved worker
439
+ // count as a third argument, and is itself async.
440
+ export declare function prepareApplication (
441
+ config: RuntimeConfiguration,
442
+ application: object,
443
+ workers?: PlatformaticRuntimeConfig['workers']
444
+ ): Promise<object>
274
445
 
275
446
  export declare function transform (
276
447
  config: RuntimeConfiguration,
package/lib/errors.js CHANGED
@@ -166,6 +166,16 @@ export const FailedToLoadExtensionError = createError(
166
166
  'Failed to load the extension "%s": %s'
167
167
  )
168
168
 
169
+ export const FailedToStartExtensionError = createError(
170
+ `${ERROR_PREFIX}_FAILED_TO_START_EXTENSION`,
171
+ 'Failed to start the extension "%s": %s'
172
+ )
173
+
174
+ export const FailedToStopExtensionError = createError(
175
+ `${ERROR_PREFIX}_FAILED_TO_STOP_EXTENSION`,
176
+ 'Failed to stop the extension "%s": %s'
177
+ )
178
+
169
179
  export const InvalidExtensionError = createError(
170
180
  `${ERROR_PREFIX}_INVALID_EXTENSION`,
171
181
  'The extension "%s" must export a setup function as its default export'
@@ -181,6 +191,26 @@ export const DuplicateITCHandlerNameError = createError(
181
191
  'The ITC command "%s" has already been registered'
182
192
  )
183
193
 
194
+ export const MetricFamilyCollisionError = createError(
195
+ `${ERROR_PREFIX}_METRIC_FAMILY_COLLISION`,
196
+ 'Extension "%s" registered metric family "%s" which collides with %s'
197
+ )
198
+
199
+ export const DuplicateExtensionHealthCheckError = createError(
200
+ `${ERROR_PREFIX}_DUPLICATE_EXTENSION_HEALTH_CHECK`,
201
+ 'The extension health %s check "%s" has already been registered by "%s"'
202
+ )
203
+
204
+ export const DuplicateExtensionHealthRouteError = createError(
205
+ `${ERROR_PREFIX}_DUPLICATE_EXTENSION_HEALTH_ROUTE`,
206
+ 'The extension "%s" failed to register health route %s %s: %s'
207
+ )
208
+
209
+ export const ExtensionHealthRoutesUnavailableError = createError(
210
+ `${ERROR_PREFIX}_EXTENSION_HEALTH_ROUTES_UNAVAILABLE`,
211
+ 'Extensions registered health routes but no health probes server is available'
212
+ )
213
+
184
214
  export const FailedToSendHealthSignalsError = createError(
185
215
  `${ERROR_PREFIX}_FAILED_TO_SEND_HEALTH_SIGNALS`,
186
216
  'Cannot send health signals from application "%s": %s'
@@ -80,7 +80,7 @@ export async function managementApiPlugin (app, opts) {
80
80
  })
81
81
 
82
82
  app.get('/scheduler', async () => {
83
- const jobs = await runtime.getScheduler()
83
+ const jobs = await runtime.getSchedulerJobs()
84
84
  return { jobs }
85
85
  })
86
86
 
@@ -220,8 +220,8 @@ export async function managementApiPlugin (app, opts) {
220
220
  app.log.debug('start profiling', { id })
221
221
 
222
222
  const options = request.body || {}
223
- await runtime.startApplicationProfiling(id, options)
224
- reply.code(200).send({})
223
+ const result = await runtime.startApplicationProfiling(id, options)
224
+ reply.code(200).send(result ?? {})
225
225
  })
226
226
 
227
227
  app.post('/applications/:id/pprof/stop', async (request, reply) => {
@@ -230,6 +230,19 @@ export async function managementApiPlugin (app, opts) {
230
230
 
231
231
  const options = request.body || {}
232
232
  const profileData = await runtime.stopApplicationProfiling(id, options)
233
+
234
+ // With allWorkers there is one profile per worker: return them as JSON
235
+ // with base64 encoded payloads since a single binary body cannot carry
236
+ // multiple profiles.
237
+ if (options.allWorkers && Array.isArray(profileData)) {
238
+ const profiles = profileData.map(({ workerIndex, profile }) => ({
239
+ workerIndex,
240
+ profile: Buffer.from(profile).toString('base64')
241
+ }))
242
+
243
+ return { profiles }
244
+ }
245
+
233
246
  reply.type('application/octet-stream').code(200).send(profileData)
234
247
  })
235
248
 
@@ -19,7 +19,7 @@ const allOperations = [
19
19
  'getApplicationOpenapiSchema',
20
20
  'getApplicationGraphqlSchema',
21
21
  'getMetrics',
22
- 'getScheduler',
22
+ 'getSchedulerJobs',
23
23
  'pauseSchedulerJob',
24
24
  'resumeSchedulerJob',
25
25
  'runSchedulerJob',
@@ -100,8 +100,8 @@ export function createManagementHandlers (runtime, allowedOperations) {
100
100
  return runtime.getMetrics(format)
101
101
  })
102
102
 
103
- register('getScheduler', async () => {
104
- return runtime.getScheduler()
103
+ register('getSchedulerJobs', async () => {
104
+ return runtime.getSchedulerJobs()
105
105
  })
106
106
 
107
107
  // Write operations
@@ -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
  }