@platformatic/runtime 4.0.0-new-config.5 → 4.0.0-new-config.6

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/config.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, resolve as resolvePath } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
 
7
7
  import {
8
+ ApplicationsPortsOverlapError,
8
9
  InspectAndInspectBrkError,
9
10
  InspectorHostError,
10
11
  InspectorPortError,
@@ -211,6 +212,100 @@ export async function prepareAddedApplications (config, entries, existingIds = [
211
212
  return prepared
212
213
  }
213
214
 
215
+ const hostnameWildcards = new Set(['0.0.0.0', '::', '[::]'])
216
+
217
+ // The listener an application declares in its own configuration, when it can be determined without
218
+ // starting it. A port coming from an environment variable which was not replaced, an ephemeral port
219
+ // and a missing server block all yield null, in which case only the start time check applies.
220
+ function declaredListener (applicationConfig) {
221
+ const server = applicationConfig?.server
222
+
223
+ if (!server) {
224
+ return null
225
+ }
226
+
227
+ const port = Number(server.port)
228
+
229
+ if (!Number.isInteger(port) || port <= 0) {
230
+ return null
231
+ }
232
+
233
+ return { port, hostname: server.hostname, perWorker: server.portAssignment === 'perWorkerIncrement' }
234
+ }
235
+
236
+ // Two declared listeners can only be compared when both hostnames are known. When a hostname is
237
+ // omitted the capability picks its own default, so unless the other side is a wildcard - which
238
+ // overlaps with anything - the comparison is left to the start time check.
239
+ function declaredListenersOverlap (first, second) {
240
+ const hostname = first.hostname?.toLowerCase()
241
+ const otherHostname = second.hostname?.toLowerCase()
242
+
243
+ // A wildcard overlaps with any other hostname, known or not
244
+ if (hostnameWildcards.has(hostname) || hostnameWildcards.has(otherHostname)) {
245
+ return true
246
+ }
247
+
248
+ // Both applications rely on the same capability default, so they do collide
249
+ if (typeof hostname === 'undefined' && typeof otherHostname === 'undefined') {
250
+ return true
251
+ }
252
+
253
+ // Only one of the two defaults is unknown, so nothing can be concluded here
254
+ if (typeof hostname === 'undefined' || typeof otherHostname === 'undefined') {
255
+ return false
256
+ }
257
+
258
+ return hostname === otherHostname
259
+ }
260
+
261
+ function describeDeclaredPorts (listener) {
262
+ const { port, last } = listener
263
+ return port === last ? `port ${port}` : `ports ${port}-${last}, one per worker`
264
+ }
265
+
266
+ // Rejects applications whose declared ports overlap before any of them is started, so that the
267
+ // failure does not depend on which worker happens to report its URL first. This is best effort:
268
+ // applications whose port cannot be determined here are checked when their workers start.
269
+ function verifyApplicationsPorts (applications) {
270
+ const listeners = []
271
+
272
+ for (const application of applications) {
273
+ // v4 keeps the validated capability configuration on the application (resolvedConfig), so the
274
+ // declared listener is read from it directly rather than being stashed on a symbol during a
275
+ // separate config-loading pass, as the v3 path did.
276
+ const listener = declaredListener(application.resolvedConfig)
277
+
278
+ // Dynamic scaling changes how many ports the application ends up using, so leave it to the start time check
279
+ if (!listener || application.workers?.dynamic) {
280
+ continue
281
+ }
282
+
283
+ const workers = listener.perWorker ? (application.workers?.static ?? 1) : 1
284
+ listeners.push({ ...listener, id: application.id, last: listener.port + workers - 1 })
285
+ }
286
+
287
+ for (let i = 0; i < listeners.length; i++) {
288
+ for (let j = i + 1; j < listeners.length; j++) {
289
+ const first = listeners[i]
290
+ const second = listeners[j]
291
+
292
+ const port = Math.max(first.port, second.port)
293
+
294
+ if (port > Math.min(first.last, second.last) || !declaredListenersOverlap(first, second)) {
295
+ continue
296
+ }
297
+
298
+ throw new ApplicationsPortsOverlapError(
299
+ first.id,
300
+ describeDeclaredPorts(first),
301
+ second.id,
302
+ describeDeclaredPorts(second),
303
+ port
304
+ )
305
+ }
306
+ }
307
+ }
308
+
214
309
  // Everything an application needs regardless of how its capability was determined. v3 discovers
215
310
  // that by loading the application's config file; v4 has it from the loader's envelope before any
216
311
  // worker exists.
@@ -303,6 +398,8 @@ export async function finalizeConfiguration (config, applications, context, prod
303
398
  await prepare(config, applications[i], defaultWorkers)
304
399
  }
305
400
 
401
+ verifyApplicationsPorts(applications)
402
+
306
403
  if (typeof config.metrics === 'boolean') {
307
404
  config.metrics = {
308
405
  enabled: config.metrics,
package/lib/errors.js CHANGED
@@ -15,6 +15,10 @@ export const AddressInUseError = createError(
15
15
  `${ERROR_PREFIX}_EADDR_IN_USE`,
16
16
  'Port %d is already in use by applications "%s" and "%s"'
17
17
  )
18
+ export const ApplicationsPortsOverlapError = createError(
19
+ `${ERROR_PREFIX}_APPLICATIONS_PORTS_OVERLAP`,
20
+ 'The applications "%s" (%s) and "%s" (%s) are both configured to listen on port %d. Applications cannot share a port: change one of the ports or make them listen on different hostnames.'
21
+ )
18
22
  export const WorkerAddressInUseError = createError(
19
23
  `${ERROR_PREFIX}_WORKER_EADDR_IN_USE`,
20
24
  'Port %d is already in use by another worker of the application "%s". Multiple workers can share a port only when the reusePort feature is available in your OS, otherwise set "server.portAssignment" to "perWorkerIncrement" in the application configuration or use a single worker.'
package/lib/generator.js CHANGED
@@ -78,6 +78,7 @@ export class RuntimeGenerator extends BaseGenerator {
78
78
  async generatePackageJson () {
79
79
  const template = {
80
80
  name: `${this.runtimeName}`,
81
+ type: 'module',
81
82
  scripts: {
82
83
  dev: this.config.devCommand ?? 'wattpm dev',
83
84
  build: this.config.buildCommand ?? 'wattpm build',
@@ -187,6 +188,15 @@ export class RuntimeGenerator extends BaseGenerator {
187
188
 
188
189
  setApplicationsConfigValues () {
189
190
  let newApplicationOrdinal = 0
191
+ // A runtime of exactly one application has no mesh of siblings to fall back on: with the v4
192
+ // entrypoint gone, a sole application that declares no port would bind nothing and be reachable
193
+ // from nowhere. So the sole application is marked as the entrypoint here -- the one place the
194
+ // whole application set is known -- and a capability that otherwise scaffolds no port reads the
195
+ // flag and exposes itself on PORT/3042 (@platformatic/node does; the framework capabilities,
196
+ // which also scaffold no port, can adopt the same flag). The count is the gate: the moment a
197
+ // second application exists, nothing is forced and a portless application stays mesh-only, which
198
+ // is what keeps "expose the one" from becoming "expose them all".
199
+ const totalApplications = this.existingApplications.length + this.applications.length
190
200
  this.applications.forEach(({ name, application }) => {
191
201
  if (!application.config) {
192
202
  // set default config
@@ -195,6 +205,7 @@ export class RuntimeGenerator extends BaseGenerator {
195
205
  const existingOrdinal = this.existingApplications.indexOf(name)
196
206
  const ordinal = existingOrdinal === -1 ? this.existingApplications.length + newApplicationOrdinal++ : existingOrdinal
197
207
  this.setApplicationPort(application, ordinal)
208
+ application.config.entrypoint = totalApplications === 1
198
209
  })
199
210
  }
200
211
 
@@ -205,14 +216,11 @@ export class RuntimeGenerator extends BaseGenerator {
205
216
  }
206
217
 
207
218
  /*
208
- The root is the one v4 configuration that is not a capability factory call: it imports
209
- `defineConfig` from wattpm, whose whole job is to type its argument.
210
- */
211
- /*
212
- The stamped plain-object form rather than an imported `defineConfig`, which is the machine-writer
213
- case the `$schema` marker exists for: the wizard writes this file before anything is installed
214
- and reads it back on a later run, so a root that imported `wattpm` could not be read in the
215
- state that produced it. A person editing the file afterwards can switch to the imported form.
219
+ The root is spelled the same way an application is: it imports `defineConfig` from wattpm and
220
+ calls it, so the editor types and checks the shape the loader would otherwise only reject at
221
+ boot. The import means the file cannot be evaluated before its dependencies are installed --
222
+ which is why `wattpm install`/`create` install the root's own dependencies before reading it to
223
+ discover the applications (see installDependencies in wattpm-utils).
216
224
  */
217
225
  serializeConfigFile (config) {
218
226
  /*
@@ -225,7 +233,13 @@ export class RuntimeGenerator extends BaseGenerator {
225
233
  // thing, and leaving them would write the placeholder's own text where an expression belongs.
226
234
  const resolved = this.resolveScaffoldedPlaceholders(rest)
227
235
 
228
- return `export default ${serializeConfiguration(resolved)}\n`
236
+ /*
237
+ The root is spelled the same way an application is: a factory call, `defineConfig` from
238
+ wattpm, whose whole job is to type its argument so the editor completes and checks the shape
239
+ the loader would otherwise only reject at boot. wattpm's defineConfig imports nothing, so the
240
+ root evaluates in the eval worker without paying for the runtime it would otherwise pull in.
241
+ */
242
+ return `import { defineConfig } from 'wattpm'\n\nexport default defineConfig(${serializeConfiguration(resolved)})\n`
229
243
  }
230
244
 
231
245
  async _getConfigFileContents () {
@@ -648,6 +662,13 @@ export class WrappedGenerator extends BaseGenerator {
648
662
  async #updateEnvironment () {
649
663
  this.addEnvVars(getRuntimeBaseEnvVars(this.config), { overwrite: false, default: true })
650
664
 
665
+ // A wrapped project is a runtime of one application by construction, so that application is the
666
+ // one that must face the network -- otherwise the wrap would leave the code it was built around
667
+ // reachable from nowhere. The port is registered here so #createConfigFile can read it back as
668
+ // `Number(process.env.PORT || 3042)`; bare PORT rather than a scoped name, because the wrapped
669
+ // application is the root's own singular one and is what a hosting platform's PORT addresses.
670
+ this.addEnvVar('PORT', 3042, { overwrite: false, default: true })
671
+
651
672
  this.addFile({
652
673
  path: '',
653
674
  file: '.env',
@@ -717,7 +738,9 @@ export class WrappedGenerator extends BaseGenerator {
717
738
  */
718
739
  const config = {
719
740
  ...this.resolveScaffoldedPlaceholders(getRuntimeWrappableProperties()),
720
- application: { config: { module: this.module } }
741
+ application: {
742
+ config: this.resolveScaffoldedPlaceholders({ module: this.module, server: { port: '{PORT}' } })
743
+ }
721
744
  }
722
745
 
723
746
  this.addFile({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "4.0.0-new-config.5",
3
+ "version": "4.0.0-new-config.6",
4
4
  "description": "Platformatic runtime",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -39,14 +39,14 @@
39
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": "4.0.0-new-config.5",
43
- "@platformatic/db": "4.0.0-new-config.5",
44
- "@platformatic/gateway": "4.0.0-new-config.5",
45
- "@platformatic/node": "4.0.0-new-config.5",
46
- "@platformatic/service": "4.0.0-new-config.5",
47
- "@platformatic/sql-graphql": "4.0.0-new-config.5",
48
- "@platformatic/sql-mapper": "4.0.0-new-config.5",
49
- "@platformatic/wattpm-pprof-capture": "4.0.0-new-config.5"
42
+ "@platformatic/composer": "4.0.0-new-config.6",
43
+ "@platformatic/db": "4.0.0-new-config.6",
44
+ "@platformatic/gateway": "4.0.0-new-config.6",
45
+ "@platformatic/service": "4.0.0-new-config.6",
46
+ "@platformatic/node": "4.0.0-new-config.6",
47
+ "@platformatic/sql-graphql": "4.0.0-new-config.6",
48
+ "@platformatic/sql-mapper": "4.0.0-new-config.6",
49
+ "@platformatic/wattpm-pprof-capture": "4.0.0-new-config.6"
50
50
  },
51
51
  "dependencies": {
52
52
  "@fastify/accepts": "^5.0.0",
@@ -78,13 +78,13 @@
78
78
  "undici": "^8.5.0",
79
79
  "undici-thread-interceptor": "^2.1.1",
80
80
  "ws": "^8.16.0",
81
- "@platformatic/basic": "4.0.0-new-config.5",
82
- "@platformatic/foundation": "4.0.0-new-config.5",
83
- "@platformatic/globals": "4.0.0-new-config.5",
84
- "@platformatic/generators": "4.0.0-new-config.5",
85
- "@platformatic/itc": "4.0.0-new-config.5",
86
- "@platformatic/tracing": "4.0.0-new-config.5",
87
- "@platformatic/metrics": "4.0.0-new-config.5"
81
+ "@platformatic/basic": "4.0.0-new-config.6",
82
+ "@platformatic/foundation": "4.0.0-new-config.6",
83
+ "@platformatic/generators": "4.0.0-new-config.6",
84
+ "@platformatic/itc": "4.0.0-new-config.6",
85
+ "@platformatic/tracing": "4.0.0-new-config.6",
86
+ "@platformatic/globals": "4.0.0-new-config.6",
87
+ "@platformatic/metrics": "4.0.0-new-config.6"
88
88
  },
89
89
  "engines": {
90
90
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/4.0.0-new-config.5.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/4.0.0-new-config.6.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",