@platformatic/runtime 3.66.0 → 3.68.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
@@ -17,6 +17,7 @@ export type PlatformaticRuntimeConfig = {
17
17
  options?: {
18
18
  [k: string]: unknown;
19
19
  };
20
+ build?: boolean;
20
21
  }
21
22
  | (
22
23
  | string
@@ -25,6 +26,7 @@ export type PlatformaticRuntimeConfig = {
25
26
  options?: {
26
27
  [k: string]: unknown;
27
28
  };
29
+ build?: boolean;
28
30
  }
29
31
  )[];
30
32
  entrypoint?: string;
@@ -43,6 +45,7 @@ export type PlatformaticRuntimeConfig = {
43
45
  };
44
46
  config?: string;
45
47
  useHttp?: boolean;
48
+ websocket?: boolean;
46
49
  reuseTcpPorts?: boolean;
47
50
  workers?:
48
51
  | number
package/index.d.ts CHANGED
@@ -250,6 +250,7 @@ export declare class ManagementClient {
250
250
  getApplicationOpenapiSchema (id: string): Promise<unknown>
251
251
  getApplicationGraphqlSchema (id: string): Promise<unknown>
252
252
  getMetrics (format?: string): Promise<{ metrics: unknown }>
253
+ getScheduler (): Promise<SchedulerJob[]>
253
254
  getSchedulerJobs (): Promise<SchedulerJob[]>
254
255
  pauseSchedulerJob (name: string): Promise<SchedulerJob>
255
256
  resumeSchedulerJob (name: string): Promise<SchedulerJob>
@@ -344,7 +345,20 @@ export interface RuntimeExtensionContext {
344
345
  health: RuntimeExtensionHealth
345
346
  }
346
347
 
348
+ export interface RuntimeExtensionBuildContext {
349
+ applicationId: string
350
+ applicationPath: string
351
+ }
352
+
353
+ export type RuntimeExtensionBuild = () => Promise<unknown>
354
+
347
355
  export interface RuntimeExtensionInstance {
356
+ preBuild?: (context: RuntimeExtensionBuildContext) => void | Promise<void>
357
+ onBuild?: (
358
+ context: RuntimeExtensionBuildContext,
359
+ build: RuntimeExtensionBuild
360
+ ) => unknown | Promise<unknown>
361
+ postBuild?: (context: RuntimeExtensionBuildContext, result: unknown) => void | Promise<void>
348
362
  start?: () => void | Promise<void>
349
363
  stop?: () => void | Promise<void>
350
364
  close?: () => void | Promise<void>
@@ -449,6 +463,7 @@ export declare class Runtime extends EventEmitter {
449
463
  off (event: 'application:worker:health:metrics', listener: (event: HealthMetricsEvent) => void): this
450
464
  off (event: string | symbol, listener: (...args: any[]) => void): this
451
465
 
466
+ getScheduler (): SchedulerJob[]
452
467
  getSchedulerJobs (): SchedulerJob[]
453
468
  pauseSchedulerJob (name: string): Promise<SchedulerJob>
454
469
  resumeSchedulerJob (name: string): Promise<SchedulerJob>
package/lib/config.js CHANGED
@@ -9,11 +9,13 @@ import {
9
9
  omitProperties,
10
10
  runtimeUnwrappablePropertiesList
11
11
  } from '@platformatic/foundation'
12
+ import { realpathSync } from 'node:fs'
12
13
  import { readdir, readFile } from 'node:fs/promises'
13
14
  import { createRequire } from 'node:module'
14
15
  import { isAbsolute, join, resolve as resolvePath } from 'node:path'
15
16
 
16
17
  import {
18
+ ApplicationIdCollisionError,
17
19
  InspectAndInspectBrkError,
18
20
  InspectorHostError,
19
21
  InspectorPortError,
@@ -295,6 +297,40 @@ export async function prepareApplication (config, application, defaultWorkers) {
295
297
  return application
296
298
  }
297
299
 
300
+ function canonicalPath (path) {
301
+ try {
302
+ return realpathSync(path)
303
+ } catch {
304
+ return resolvePath(path)
305
+ }
306
+ }
307
+
308
+ // An autoloaded directory and an explicitly configured entry with the same id are the same application
309
+ // only when they point to the same place: either the configured path resolves to the autoloaded
310
+ // directory, or the entry is external and the autoloaded directory is where "resolve" would put it.
311
+ // When they don't, returns a description of the configured entry to report in the error.
312
+ function conflictingApplicationSource (root, resolvedApplicationsPath, existing, entryPath) {
313
+ if (existing.path) {
314
+ // The path still contains a placeholder, which means the environment variable was not replaced.
315
+ // There is nothing to compare in that case.
316
+ if (existing.path.match(/^\{.*\}$/)) {
317
+ return null
318
+ }
319
+
320
+ const existingPath = isAbsolute(existing.path) ? existing.path : resolvePath(root, existing.path)
321
+
322
+ return canonicalPath(existingPath) === canonicalPath(entryPath) ? null : `the path "${existing.path}"`
323
+ }
324
+
325
+ if (existing.url) {
326
+ const resolvedPath = join(resolvedApplicationsPath, existing.id)
327
+
328
+ return canonicalPath(resolvedPath) === canonicalPath(entryPath) ? null : `the URL "${existing.url}"`
329
+ }
330
+
331
+ return null
332
+ }
333
+
298
334
  function isApplicationEnabled (application, environment) {
299
335
  const { enabled } = application
300
336
 
@@ -378,7 +414,11 @@ export async function transform (config, _, context) {
378
414
  const { exclude = [], mappings = {} } = config.autoload
379
415
  let { path } = config.autoload
380
416
 
381
- path = resolvePath(config[kMetadata].root, path)
417
+ // Capture these before the loop, as config is shadowed in its body
418
+ const root = config[kMetadata].root
419
+ const resolvedApplicationsPath = resolvePath(root, config.resolvedApplicationsBasePath ?? 'external')
420
+
421
+ path = resolvePath(root, path)
382
422
  const entries = await readdir(path, { withFileTypes: true })
383
423
 
384
424
  for (let i = 0; i < entries.length; ++i) {
@@ -403,7 +443,21 @@ export async function transform (config, _, context) {
403
443
  const existingApplicationId = applications.findIndex(application => application.id === id)
404
444
 
405
445
  if (existingApplicationId !== -1) {
406
- applications[existingApplicationId] = { ...application, ...applications[existingApplicationId] }
446
+ const existing = applications[existingApplicationId]
447
+
448
+ // Merging on the id alone can boot local code where the configuration named a repository, as
449
+ // the autoloaded path survives next to the configured url. Reject the duplicate instead: an id
450
+ // is the mesh hostname, the injected PLT_<ID>_URL name, the metrics label and the argument of
451
+ // "wattpm inject", so two different applications cannot share one.
452
+ if (isApplicationEnabled(existing, environment)) {
453
+ const source = conflictingApplicationSource(root, resolvedApplicationsPath, existing, entryPath)
454
+
455
+ if (source) {
456
+ throw new ApplicationIdCollisionError(id, entryPath, source)
457
+ }
458
+ }
459
+
460
+ applications[existingApplicationId] = { ...application, ...existing }
407
461
  } else {
408
462
  applications.push(application)
409
463
  }
package/lib/errors.js CHANGED
@@ -102,6 +102,10 @@ export const InvalidEntrypointError = createError(
102
102
  `${ERROR_PREFIX}_INVALID_ENTRYPOINT`,
103
103
  "Invalid entrypoint: '%s' does not exist"
104
104
  )
105
+ export const ApplicationIdCollisionError = createError(
106
+ `${ERROR_PREFIX}_APPLICATION_ID_COLLISION`,
107
+ 'The application id "%s" is used by the autoloaded directory "%s" and by a different application defined in the configuration file via %s. Application ids must be unique.'
108
+ )
105
109
  export const MissingEntrypointError = createError(
106
110
  `${ERROR_PREFIX}_MISSING_ENTRYPOINT`,
107
111
  'Missing application entrypoint.'
@@ -191,6 +195,11 @@ export const DuplicateITCHandlerNameError = createError(
191
195
  'The ITC command "%s" has already been registered'
192
196
  )
193
197
 
198
+ export const RuntimeExtensionBuildAlreadyCalledError = createError(
199
+ `${ERROR_PREFIX}_EXTENSION_BUILD_ALREADY_CALLED`,
200
+ 'The build function can only be called once by each runtime extension.'
201
+ )
202
+
194
203
  export const MetricFamilyCollisionError = createError(
195
204
  `${ERROR_PREFIX}_METRIC_FAMILY_COLLISION`,
196
205
  'Extension "%s" registered metric family "%s" which collides with %s'
package/lib/runtime.js CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  MissingPprofCapture,
55
55
  ReservedITCHandlerNameError,
56
56
  RuntimeAbortedError,
57
+ RuntimeExtensionBuildAlreadyCalledError,
57
58
  WorkerInterceptorJoinTimeoutError,
58
59
  WorkerNotFoundError
59
60
  } from './errors.js'
@@ -921,19 +922,63 @@ export class Runtime extends EventEmitter {
921
922
 
922
923
  async buildApplication (id) {
923
924
  const application = await this.#getApplicationById(id)
925
+ const applicationConfig = this.#applications.get(id)
926
+ const context = Object.freeze({
927
+ applicationId: id,
928
+ applicationPath: applicationConfig.path
929
+ })
924
930
 
925
931
  this.emitAndNotify('application:building', id)
926
- try {
927
- await sendViaITC(application, 'build')
928
- this.emitAndNotify('application:built', id)
929
- } catch (e) {
930
- // The application exports no meta, return an empty object
931
- if (e.code === 'PLT_ITC_HANDLER_NOT_FOUND') {
932
- return {}
932
+ for (const extension of this.#extensions) {
933
+ await extension.instance?.preBuild?.(context)
934
+ }
935
+
936
+ let buildHandlerMissing = false
937
+ const build = this.#extensions.reduceRight(
938
+ (next, extension) => {
939
+ if (typeof extension.instance?.onBuild !== 'function') {
940
+ return next
941
+ }
942
+
943
+ return () => {
944
+ let called = false
945
+ const build = () => {
946
+ if (called) {
947
+ throw new RuntimeExtensionBuildAlreadyCalledError()
948
+ }
949
+
950
+ called = true
951
+ return next()
952
+ }
953
+
954
+ return extension.instance.onBuild(context, build)
955
+ }
956
+ },
957
+ async () => {
958
+ try {
959
+ return await sendViaITC(application, 'build')
960
+ } catch (e) {
961
+ // The application exports no meta, return an empty object
962
+ if (e.code === 'PLT_ITC_HANDLER_NOT_FOUND') {
963
+ buildHandlerMissing = true
964
+ return {}
965
+ }
966
+
967
+ throw e
968
+ }
933
969
  }
970
+ )
934
971
 
935
- throw e
972
+ const result = await build()
973
+
974
+ for (const extension of [...this.#extensions].reverse()) {
975
+ await extension.instance?.postBuild?.(context, result)
936
976
  }
977
+
978
+ if (!buildHandlerMissing) {
979
+ this.emitAndNotify('application:built', id)
980
+ }
981
+ return result
937
982
  }
938
983
 
939
984
  async startApplicationProfiling (id, options = {}, ensureStarted = true) {
@@ -2147,6 +2192,10 @@ export class Runtime extends EventEmitter {
2147
2192
  return this.#scheduler?.getJobs() ?? []
2148
2193
  }
2149
2194
 
2195
+ getScheduler () {
2196
+ return this.getSchedulerJobs()
2197
+ }
2198
+
2150
2199
  pauseSchedulerJob (name) {
2151
2200
  return this.#schedulerOrThrow().pauseJob(name)
2152
2201
  }
@@ -4343,8 +4392,7 @@ export class Runtime extends EventEmitter {
4343
4392
  async #loadExtensions () {
4344
4393
  let extensions = this.#config.extensions
4345
4394
 
4346
- // Do not load extensions when building applications
4347
- if (!extensions || this.#context.build) {
4395
+ if (!extensions) {
4348
4396
  return
4349
4397
  }
4350
4398
 
@@ -4353,7 +4401,14 @@ export class Runtime extends EventEmitter {
4353
4401
  }
4354
4402
 
4355
4403
  for (const extension of extensions) {
4356
- const { path, options } = typeof extension === 'string' ? { path: extension } : extension
4404
+ const { path, options, build } = typeof extension === 'string' ? { path: extension } : extension
4405
+
4406
+ // Runtime extensions historically were not loaded by `wattpm build`.
4407
+ // Preserve that behavior unless an extension explicitly opts into the
4408
+ // build lifecycle.
4409
+ if (this.#context.build && !build) {
4410
+ continue
4411
+ }
4357
4412
 
4358
4413
  let imported
4359
4414
  try {
@@ -140,10 +140,14 @@ export class Controller extends EventEmitter {
140
140
  }
141
141
 
142
142
  if (appConfig.config) {
143
- // Parse the configuration file the first time to obtain the schema
143
+ // Parse the configuration file the first time to obtain the schema. This load is thrown away:
144
+ // the capability loads the configuration again below and that is the configuration the
145
+ // application actually runs on. This one only has to yield $schema and module, so it must not
146
+ // diverge from the second load in any way the user can observe: it deliberately applies no
147
+ // onMissingEnv fallback and emits no strictEnv report, both of which are left to the load the
148
+ // application is built from.
144
149
  const unvalidatedConfig = await loadConfiguration(appConfig.config, null, {
145
- onMissingEnv: this.#context.fetchApplicationUrl,
146
- strictEnv: this.#context.strictEnv
150
+ strictEnv: false
147
151
  })
148
152
  const pkg = await loadConfigurationModule(appConfig.path, unvalidatedConfig)
149
153
  this.capability = await pkg.create(appConfig.path, appConfig.config, this.#context)
@@ -215,7 +219,7 @@ export class Controller extends EventEmitter {
215
219
  }
216
220
  }
217
221
 
218
- const listen = !!this.applicationConfig.useHttp
222
+ const listen = !!(this.applicationConfig.useHttp || this.applicationConfig.websocket)
219
223
 
220
224
  try {
221
225
  await this.capability.start({ listen })
@@ -264,7 +268,12 @@ export class Controller extends EventEmitter {
264
268
 
265
269
  async listen () {
266
270
  // This server is not an entrypoint or already listened in start. Behave as no-op.
267
- if (!this.applicationConfig.entrypoint || this.applicationConfig.useHttp || this.#listening) {
271
+ if (
272
+ !this.applicationConfig.entrypoint ||
273
+ this.applicationConfig.useHttp ||
274
+ this.applicationConfig.websocket ||
275
+ this.#listening
276
+ ) {
268
277
  return
269
278
  }
270
279
 
@@ -274,7 +274,7 @@ async function main () {
274
274
  let serverConfig = null
275
275
  if (runtimeConfig.server && applicationConfig.entrypoint) {
276
276
  serverConfig = runtimeConfig.server
277
- } else if (applicationConfig.useHttp) {
277
+ } else if (applicationConfig.useHttp || applicationConfig.websocket) {
278
278
  serverConfig = {
279
279
  port: 0,
280
280
  hostname: '127.0.0.1',
@@ -74,6 +74,10 @@ export class ManagementClient {
74
74
  return this.#send('getSchedulerJobs')
75
75
  }
76
76
 
77
+ getScheduler () {
78
+ return this.getSchedulerJobs()
79
+ }
80
+
77
81
  pauseSchedulerJob (name) {
78
82
  return this.#send('pauseSchedulerJob', { name })
79
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.66.0",
3
+ "version": "3.68.0",
4
4
  "description": "Platformatic runtime",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -36,17 +36,17 @@
36
36
  "pino-sentry-transport": "^1.6.0",
37
37
  "split2": "^4.2.0",
38
38
  "tstyche": "^6.2.0",
39
- "typescript": "^5.9.3",
39
+ "typescript": "~6.0.3",
40
40
  "undici-oidc-interceptor": "^0.9.0",
41
41
  "why-is-node-running": "^2.2.2",
42
- "@platformatic/composer": "3.66.0",
43
- "@platformatic/gateway": "3.66.0",
44
- "@platformatic/service": "3.66.0",
45
- "@platformatic/sql-graphql": "3.66.0",
46
- "@platformatic/db": "3.66.0",
47
- "@platformatic/sql-mapper": "3.66.0",
48
- "@platformatic/wattpm-pprof-capture": "3.66.0",
49
- "@platformatic/node": "3.66.0"
42
+ "@platformatic/composer": "3.68.0",
43
+ "@platformatic/db": "3.68.0",
44
+ "@platformatic/gateway": "3.68.0",
45
+ "@platformatic/node": "3.68.0",
46
+ "@platformatic/service": "3.68.0",
47
+ "@platformatic/sql-graphql": "3.68.0",
48
+ "@platformatic/sql-mapper": "3.68.0",
49
+ "@platformatic/wattpm-pprof-capture": "3.68.0"
50
50
  },
51
51
  "dependencies": {
52
52
  "@fastify/accepts": "^5.0.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.66.0",
81
- "@platformatic/foundation": "3.66.0",
82
- "@platformatic/itc": "3.66.0",
83
- "@platformatic/metrics": "3.66.0",
84
- "@platformatic/globals": "3.66.0",
85
- "@platformatic/generators": "3.66.0",
86
- "@platformatic/telemetry": "3.66.0"
80
+ "@platformatic/generators": "3.68.0",
81
+ "@platformatic/globals": "3.68.0",
82
+ "@platformatic/itc": "3.68.0",
83
+ "@platformatic/metrics": "3.68.0",
84
+ "@platformatic/telemetry": "3.68.0",
85
+ "@platformatic/basic": "3.68.0",
86
+ "@platformatic/foundation": "3.68.0"
87
87
  },
88
88
  "engines": {
89
89
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.66.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.68.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -42,6 +42,10 @@
42
42
  "options": {
43
43
  "type": "object",
44
44
  "additionalProperties": true
45
+ },
46
+ "build": {
47
+ "type": "boolean",
48
+ "default": false
45
49
  }
46
50
  }
47
51
  },
@@ -67,6 +71,10 @@
67
71
  "options": {
68
72
  "type": "object",
69
73
  "additionalProperties": true
74
+ },
75
+ "build": {
76
+ "type": "boolean",
77
+ "default": false
70
78
  }
71
79
  }
72
80
  }
@@ -134,6 +142,9 @@
134
142
  "useHttp": {
135
143
  "type": "boolean"
136
144
  },
145
+ "websocket": {
146
+ "type": "boolean"
147
+ },
137
148
  "reuseTcpPorts": {
138
149
  "type": "boolean",
139
150
  "default": true
@@ -566,6 +577,9 @@
566
577
  "useHttp": {
567
578
  "type": "boolean"
568
579
  },
580
+ "websocket": {
581
+ "type": "boolean"
582
+ },
569
583
  "reuseTcpPorts": {
570
584
  "type": "boolean",
571
585
  "default": true
@@ -996,6 +1010,9 @@
996
1010
  "useHttp": {
997
1011
  "type": "boolean"
998
1012
  },
1013
+ "websocket": {
1014
+ "type": "boolean"
1015
+ },
999
1016
  "reuseTcpPorts": {
1000
1017
  "type": "boolean",
1001
1018
  "default": true
@@ -1426,6 +1443,9 @@
1426
1443
  "useHttp": {
1427
1444
  "type": "boolean"
1428
1445
  },
1446
+ "websocket": {
1447
+ "type": "boolean"
1448
+ },
1429
1449
  "reuseTcpPorts": {
1430
1450
  "type": "boolean",
1431
1451
  "default": true