@platformatic/runtime 0.27.0 → 0.28.1

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.
@@ -9,8 +9,7 @@
9
9
  }
10
10
  },
11
11
  "plugins": {
12
- "paths": ["./plugin.js"],
13
- "hotReload": false
12
+ "paths": ["./plugin.js"]
14
13
  },
15
14
  "service": {
16
15
  "openapi": true
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://platformatic.dev/schemas/v0.20.0/runtime",
3
+ "entrypoint": "serviceApp",
4
+ "allowCycles": true,
5
+ "hotReload": false,
6
+ "autoload": {
7
+ "path": "../monorepo",
8
+ "exclude": ["docs", "composerApp"],
9
+ "mappings": {
10
+ "serviceApp": {
11
+ "id": "serviceApp",
12
+ "config": "platformatic.service-client-without-id.json"
13
+ },
14
+ "serviceAppWithLogger": {
15
+ "id": "with-logger",
16
+ "config": "platformatic.service.json"
17
+ },
18
+ "serviceAppWithMultiplePlugins": {
19
+ "id": "multi-plugin-service",
20
+ "config": "platformatic.service.json"
21
+ }
22
+ }
23
+ }
24
+ }
@@ -8,8 +8,7 @@
8
8
  }
9
9
  },
10
10
  "plugins": {
11
- "paths": ["./plugin.js"],
12
- "hotReload": false
11
+ "paths": ["./plugin.js"]
13
12
  },
14
13
  "service": {
15
14
  "openapi": true
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://platformatic.dev/schemas/v0.20.0/service",
3
+ "server": {
4
+ "hostname": "127.0.0.1",
5
+ "port": 0
6
+ },
7
+ "service": {
8
+ "openapi": true
9
+ },
10
+ "plugins": {
11
+ "paths": [
12
+ "plugin.js"
13
+ ]
14
+ },
15
+ "clients": [
16
+ {
17
+ "path": "./with-logger",
18
+ "url": "{PLT_WITH_LOGGER_URL}"
19
+ }
20
+ ]
21
+ }
@@ -8,13 +8,13 @@
8
8
  "openapi": true
9
9
  },
10
10
  "plugins": {
11
- "hotReload": true,
12
11
  "paths": [
13
12
  "plugin.js"
14
13
  ]
15
14
  },
16
15
  "clients": [
17
16
  {
17
+ "serviceId": "with-logger",
18
18
  "path": "./with-logger",
19
19
  "url": "{PLT_WITH_LOGGER_URL}"
20
20
  }
@@ -5,7 +5,6 @@
5
5
  "port": 0
6
6
  },
7
7
  "plugins": {
8
- "hotReload": true,
9
8
  "paths": [
10
9
  "plugin.js"
11
10
  ]
@@ -0,0 +1,14 @@
1
+ 'use strict'
2
+ const assert = require('node:assert')
3
+ const { request } = require('undici')
4
+ const { startCommandInRuntime } = require('../lib/unified-api')
5
+
6
+ async function main () {
7
+ const entrypoint = await startCommandInRuntime(['-c', process.argv[2]])
8
+ const res = await request(entrypoint)
9
+
10
+ assert.strictEqual(res.statusCode, 200)
11
+ process.exit(42)
12
+ }
13
+
14
+ main()
package/lib/api.js ADDED
@@ -0,0 +1,257 @@
1
+ 'use strict'
2
+
3
+ const { once, EventEmitter } = require('node:events')
4
+ const { randomUUID } = require('node:crypto')
5
+
6
+ const MAX_LISTENERS_COUNT = 100
7
+
8
+ class RuntimeApiClient extends EventEmitter {
9
+ #worker
10
+
11
+ constructor (worker) {
12
+ super()
13
+ this.setMaxListeners(MAX_LISTENERS_COUNT)
14
+
15
+ this.#worker = worker
16
+ this.#worker.on('message', (message) => {
17
+ if (message.operationId) {
18
+ this.emit(message.operationId, message)
19
+ }
20
+ })
21
+ }
22
+
23
+ async start () {
24
+ return this.#sendCommand('plt:start-services')
25
+ }
26
+
27
+ async close () {
28
+ await this.#sendCommand('plt:stop-services')
29
+ await once(this.#worker, 'exit')
30
+ }
31
+
32
+ async restart () {
33
+ return this.#sendCommand('plt:restart-services')
34
+ }
35
+
36
+ async getServices () {
37
+ return this.#sendCommand('plt:get-services')
38
+ }
39
+
40
+ async getServiceDetails (id) {
41
+ return this.#sendCommand('plt:get-service-details', { id })
42
+ }
43
+
44
+ async getServiceConfig (id) {
45
+ return this.#sendCommand('plt:get-service-config', { id })
46
+ }
47
+
48
+ async startService (id) {
49
+ return this.#sendCommand('plt:start-service', { id })
50
+ }
51
+
52
+ async stopService (id) {
53
+ return this.#sendCommand('plt:stop-service', { id })
54
+ }
55
+
56
+ async inject (id, injectParams) {
57
+ return this.#sendCommand('plt:inject', { id, injectParams })
58
+ }
59
+
60
+ async #sendCommand (command, params = {}) {
61
+ const operationId = randomUUID()
62
+
63
+ this.#worker.postMessage({ operationId, command, params })
64
+ const [message] = await once(this, operationId)
65
+
66
+ const { error, data } = message
67
+ if (error !== null) {
68
+ throw new Error(error)
69
+ }
70
+
71
+ return JSON.parse(data)
72
+ }
73
+ }
74
+
75
+ class RuntimeApi {
76
+ #services
77
+ #dispatcher
78
+
79
+ constructor (services, dispatcher) {
80
+ this.#services = services
81
+ this.#dispatcher = dispatcher
82
+ }
83
+
84
+ async startListening (parentPort) {
85
+ parentPort.on('message', async (message) => {
86
+ const command = message?.command
87
+ if (command) {
88
+ const res = await this.#executeCommand(message)
89
+ parentPort.postMessage(res)
90
+
91
+ if (command === 'plt:stop-services') {
92
+ process.exit() // Exit the worker thread.
93
+ }
94
+ return
95
+ }
96
+ await this.#handleProcessLevelEvent(message)
97
+ })
98
+ }
99
+
100
+ async #handleProcessLevelEvent (message) {
101
+ for (const service of this.#services.values()) {
102
+ await service.handleProcessLevelEvent(message)
103
+ }
104
+ }
105
+
106
+ async #executeCommand (message) {
107
+ const { operationId, command, params } = message
108
+ try {
109
+ const res = await this.#runCommandHandler(command, params)
110
+ return { operationId, error: null, data: JSON.stringify(res || null) }
111
+ } catch (err) {
112
+ return { operationId, error: err.message }
113
+ }
114
+ }
115
+
116
+ async #runCommandHandler (command, params) {
117
+ switch (command) {
118
+ case 'plt:start-services':
119
+ return this.#startServices(params)
120
+ case 'plt:stop-services':
121
+ return this.#stopServices(params)
122
+ case 'plt:restart-services':
123
+ return this.#restartServices(params)
124
+ case 'plt:get-services':
125
+ return this.#getServices(params)
126
+ case 'plt:get-service-details':
127
+ return this.#getServiceDetails(params)
128
+ case 'plt:get-service-config':
129
+ return this.#getServiceConfig(params)
130
+ case 'plt:start-service':
131
+ return this.#startService(params)
132
+ case 'plt:stop-service':
133
+ return this.#stopService(params)
134
+ case 'plt:inject':
135
+ return this.#inject(params)
136
+ /* c8 ignore next 2 */
137
+ default:
138
+ throw new Error(`Unknown Runtime API command: '${command}'`)
139
+ }
140
+ }
141
+
142
+ async #startServices () {
143
+ let entrypointUrl = null
144
+ for (const service of this.#services.values()) {
145
+ await service.start()
146
+
147
+ if (service.appConfig.entrypoint) {
148
+ entrypointUrl = service.server.url
149
+ }
150
+
151
+ const serviceUrl = new URL(service.appConfig.localUrl)
152
+ this.#dispatcher.route(serviceUrl.host, service.server)
153
+ }
154
+ return entrypointUrl
155
+ }
156
+
157
+ async #stopServices () {
158
+ for (const service of this.#services.values()) {
159
+ const serviceStatus = service.getStatus()
160
+ if (serviceStatus === 'started') {
161
+ await service.stop()
162
+ }
163
+ }
164
+ }
165
+
166
+ async #restartServices () {
167
+ let entrypointUrl = null
168
+ for (const service of this.#services.values()) {
169
+ if (service.server) {
170
+ await service.restart(true)
171
+ }
172
+
173
+ if (service.appConfig.entrypoint) {
174
+ entrypointUrl = service.server.url
175
+ }
176
+
177
+ const serviceUrl = new URL(service.appConfig.localUrl)
178
+ this.#dispatcher.route(serviceUrl.host, service.server)
179
+ }
180
+ return entrypointUrl
181
+ }
182
+
183
+ #getServices () {
184
+ const services = { services: [] }
185
+
186
+ for (const service of this.#services.values()) {
187
+ const serviceId = service.appConfig.id
188
+ const serviceDetails = this.#getServiceDetails({ id: serviceId })
189
+ if (serviceDetails.entrypoint) {
190
+ services.entrypoint = serviceId
191
+ }
192
+ services.services.push(serviceDetails)
193
+ }
194
+
195
+ return services
196
+ }
197
+
198
+ #getServiceById (id) {
199
+ const service = this.#services.get(id)
200
+
201
+ if (!service) {
202
+ throw new Error(`Service with id '${id}' not found`)
203
+ }
204
+
205
+ return service
206
+ }
207
+
208
+ #getServiceDetails ({ id }) {
209
+ const service = this.#getServiceById(id)
210
+ const status = service.getStatus()
211
+
212
+ const { entrypoint, dependencies, localUrl } = service.appConfig
213
+ return { id, status, localUrl, entrypoint, dependencies }
214
+ }
215
+
216
+ #getServiceConfig ({ id }) {
217
+ const service = this.#getServiceById(id)
218
+
219
+ const { config } = service
220
+ if (!config) {
221
+ throw new Error(`Service with id '${id}' is not started`)
222
+ }
223
+
224
+ return config.configManager.current
225
+ }
226
+
227
+ async #startService ({ id }) {
228
+ const service = this.#getServiceById(id)
229
+ await service.start()
230
+ }
231
+
232
+ async #stopService ({ id }) {
233
+ const service = this.#getServiceById(id)
234
+ await service.stop()
235
+ }
236
+
237
+ async #inject ({ id, injectParams }) {
238
+ const service = this.#getServiceById(id)
239
+
240
+ const serviceStatus = service.getStatus()
241
+ if (serviceStatus !== 'started') {
242
+ throw new Error(`Service with id '${id}' is not started`)
243
+ }
244
+
245
+ const res = await service.server.inject(injectParams)
246
+ // Return only serializable properties.
247
+ return {
248
+ statusCode: res.statusCode,
249
+ statusMessage: res.statusMessage,
250
+ headers: res.headers,
251
+ body: res.body,
252
+ payload: res.payload
253
+ }
254
+ }
255
+ }
256
+
257
+ module.exports = { RuntimeApi, RuntimeApiClient }
package/lib/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  const { once } = require('node:events')
3
- const { dirname } = require('node:path')
3
+ const { dirname, basename } = require('node:path')
4
4
  const { FileWatcher } = require('@platformatic/utils')
5
5
  const {
6
6
  buildServer,
@@ -27,6 +27,14 @@ class PlatformaticApp {
27
27
  this.#logger = logger
28
28
  }
29
29
 
30
+ getStatus () {
31
+ if (this.#started) {
32
+ return 'started'
33
+ } else {
34
+ return 'stopped'
35
+ }
36
+ }
37
+
30
38
  async restart (force) {
31
39
  if (this.#restarting) {
32
40
  return
@@ -54,7 +62,9 @@ class PlatformaticApp {
54
62
  throw new Error('application is already started')
55
63
  }
56
64
 
57
- await this.#initializeConfig()
65
+ if (!this.#restarting) {
66
+ await this.#initializeConfig()
67
+ }
58
68
  const { configManager } = this.config
59
69
  const config = configManager.current
60
70
 
@@ -77,9 +87,6 @@ class PlatformaticApp {
77
87
  this.#logAndExit(err)
78
88
  }
79
89
 
80
- this.server.platformatic.configManager = configManager
81
- this.server.platformatic.config = config
82
-
83
90
  if (config.plugins !== undefined && this.#originalWatch !== false) {
84
91
  this.#startFileWatching()
85
92
  }
@@ -110,26 +117,11 @@ class PlatformaticApp {
110
117
  this.#started = false
111
118
  }
112
119
 
113
- async handleProcessLevelEvent ({ msg, signal, err }) {
114
- if (msg === 'plt:start') {
115
- await this.start()
116
- return
117
- }
118
-
120
+ async handleProcessLevelEvent ({ signal, err }) {
119
121
  if (!this.server) {
120
122
  return false
121
123
  }
122
124
 
123
- if (msg === 'plt:restart') {
124
- await this.restart(true)
125
- return
126
- }
127
-
128
- if (msg === 'plt:stop') {
129
- await this.stop()
130
- return
131
- }
132
-
133
125
  if (signal === 'SIGUSR2') {
134
126
  this.server.log.info('reloading configuration')
135
127
  await this.restart()
@@ -161,49 +153,43 @@ class PlatformaticApp {
161
153
  return appConfig.localServiceEnvVars.get(key)
162
154
  }
163
155
  })
164
- const { args, configManager } = this.config
156
+ const { configManager } = this.config
165
157
 
166
- if (appConfig._configOverrides instanceof Map) {
167
- try {
168
- appConfig._configOverrides.forEach((value, key) => {
169
- if (typeof key !== 'string') {
170
- throw new Error('config path must be a string.')
171
- }
172
-
173
- const parts = key.split('.')
174
- let next = configManager.current
175
- let obj
176
-
177
- for (let i = 0; next !== undefined && i < parts.length; ++i) {
178
- obj = next
179
- next = obj[parts[i]]
180
- }
181
-
182
- if (next !== undefined) {
183
- obj[parts.at(-1)] = value
184
- }
185
- })
186
- } catch (err) {
187
- configManager.stopWatching()
188
- throw err
158
+ function applyOverrides () {
159
+ if (appConfig._configOverrides instanceof Map) {
160
+ try {
161
+ appConfig._configOverrides.forEach((value, key) => {
162
+ if (typeof key !== 'string') {
163
+ throw new Error('config path must be a string.')
164
+ }
165
+
166
+ const parts = key.split('.')
167
+ let next = configManager.current
168
+ let obj
169
+
170
+ for (let i = 0; next !== undefined && i < parts.length; ++i) {
171
+ obj = next
172
+ next = obj[parts[i]]
173
+ }
174
+
175
+ if (next !== undefined) {
176
+ obj[parts.at(-1)] = value
177
+ }
178
+ })
179
+ } catch (err) {
180
+ configManager.stopWatching()
181
+ throw err
182
+ }
189
183
  }
190
184
  }
191
185
 
192
- this.#setuplogger(configManager)
193
-
194
- this.#hotReload = args.hotReload && this.appConfig.hotReload
186
+ applyOverrides()
195
187
 
196
- if (configManager.current.plugins) {
197
- if (this.#hotReload) {
198
- this.#hotReload = configManager.current.plugins.hotReload
199
- }
200
-
201
- configManager.current.plugins.hotReload = false
202
- }
188
+ this.#hotReload = this.appConfig.hotReload
203
189
 
204
190
  configManager.on('update', async (newConfig) => {
205
- /* c8 ignore next 4 */
206
191
  this.server.platformatic.config = newConfig
192
+ applyOverrides()
207
193
  this.server.log.debug('config changed')
208
194
  this.server.log.trace({ newConfig }, 'new config')
209
195
  await this.restart()
@@ -228,11 +214,13 @@ class PlatformaticApp {
228
214
  #startFileWatching () {
229
215
  const server = this.server
230
216
  const { configManager } = server.platformatic
217
+ // TODO FileWatcher and ConfigManager both watch the configuration file
218
+ // we should remove the watching from the ConfigManager
231
219
  const fileWatcher = new FileWatcher({
232
220
  path: dirname(configManager.fullPath),
233
221
  /* c8 ignore next 2 */
234
222
  allowToWatch: this.#originalWatch?.allow,
235
- watchIgnore: this.#originalWatch?.ignore
223
+ watchIgnore: [...(this.#originalWatch?.ignore || []), basename(configManager.fullPath)]
236
224
  })
237
225
 
238
226
  fileWatcher.on('update', async () => {
package/lib/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  const { readFile, readdir } = require('node:fs/promises')
3
- const { join, resolve: pathResolve } = require('node:path')
3
+ const { basename, join, resolve: pathResolve } = require('node:path')
4
4
  const Topo = require('@hapi/topo')
5
5
  const ConfigManager = require('@platformatic/config')
6
6
  const { schema } = require('./schema')
@@ -101,7 +101,7 @@ async function parseClientsAndComposer (configManager) {
101
101
  }
102
102
 
103
103
  if (dep.origin === `{${err.key}}`) {
104
- service.localServiceEnvVars.set(err.key, `${clientName}.plt.local`)
104
+ service.localServiceEnvVars.set(err.key, `http://${clientName}.plt.local`)
105
105
  }
106
106
  }
107
107
  }
@@ -118,6 +118,7 @@ async function parseClientsAndComposer (configManager) {
118
118
  const promises = parsed.clients.map((client) => {
119
119
  // eslint-disable-next-line no-async-promise-executor
120
120
  return new Promise(async (resolve, reject) => {
121
+ let clientName = client.serviceId ?? ''
121
122
  let clientUrl
122
123
  let missingKey
123
124
 
@@ -135,11 +136,15 @@ async function parseClientsAndComposer (configManager) {
135
136
  }
136
137
 
137
138
  const isLocal = missingKey && client.url === `{${missingKey}}`
138
- const clientAbsolutePath = pathResolve(service.path, client.path)
139
- const clientPackageJson = join(clientAbsolutePath, 'package.json')
140
- const clientMetadata = JSON.parse(await readFile(clientPackageJson, 'utf8'))
139
+
141
140
  /* c8 ignore next 20 - unclear why c8 is unhappy for nearly 20 lines here */
142
- const clientName = clientMetadata.name ?? ''
141
+ if (!clientName) {
142
+ const clientAbsolutePath = pathResolve(service.path, client.path)
143
+ const clientPackageJson = join(clientAbsolutePath, 'package.json')
144
+ const clientMetadata = JSON.parse(await readFile(clientPackageJson, 'utf8'))
145
+
146
+ clientName = clientMetadata.name ?? ''
147
+ }
143
148
 
144
149
  if (clientUrl === undefined) {
145
150
  // Combine the service name with the client name to avoid collisions
@@ -156,8 +161,8 @@ async function parseClientsAndComposer (configManager) {
156
161
 
157
162
  const dependency = configManager.current.serviceMap.get(clientName)
158
163
 
164
+ /* c8 ignore next 4 */
159
165
  if (dependency === undefined) {
160
- /* c8 ignore next 3 */
161
166
  reject(new Error(`service '${service.id}' has unknown dependency: '${clientName}'`))
162
167
  return
163
168
  }
@@ -206,4 +211,27 @@ platformaticRuntime.configManagerConfig = {
206
211
  }
207
212
  }
208
213
 
209
- module.exports = { platformaticRuntime }
214
+ async function wrapConfigInRuntimeConfig ({ configManager, args }) {
215
+ /* c8 ignore next */
216
+ const id = basename(configManager.dirname) || 'main'
217
+ const wrapperConfig = {
218
+ $schema: schema.$id,
219
+ entrypoint: id,
220
+ allowCycles: false,
221
+ hotReload: true,
222
+ services: [
223
+ {
224
+ id,
225
+ path: configManager.dirname,
226
+ config: configManager.fullPath
227
+ }
228
+ ]
229
+ }
230
+ const cm = new ConfigManager({ source: wrapperConfig, schema })
231
+
232
+ await _transformConfig(cm)
233
+ await cm.parseAndValidate()
234
+ return cm
235
+ }
236
+
237
+ module.exports = { platformaticRuntime, wrapConfigInRuntimeConfig }