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

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,23 @@ 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
+ //
200
+ // Counted as distinct names rather than as two array lengths added: the create wizard seeds its
201
+ // uniqueness set from `existingApplications` and pushes each new name back into it, so a freshly
202
+ // scaffolded application is present in both `existingApplications` and `applications`. A plain sum
203
+ // would double-count it and never see a single-application project as single.
204
+ const totalApplications = new Set([
205
+ ...this.existingApplications,
206
+ ...this.applications.map(({ name }) => name)
207
+ ]).size
190
208
  this.applications.forEach(({ name, application }) => {
191
209
  if (!application.config) {
192
210
  // set default config
@@ -195,6 +213,7 @@ export class RuntimeGenerator extends BaseGenerator {
195
213
  const existingOrdinal = this.existingApplications.indexOf(name)
196
214
  const ordinal = existingOrdinal === -1 ? this.existingApplications.length + newApplicationOrdinal++ : existingOrdinal
197
215
  this.setApplicationPort(application, ordinal)
216
+ application.config.entrypoint = totalApplications === 1
198
217
  })
199
218
  }
200
219
 
@@ -205,14 +224,11 @@ export class RuntimeGenerator extends BaseGenerator {
205
224
  }
206
225
 
207
226
  /*
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.
227
+ The root is spelled the same way an application is: it imports `defineConfig` from wattpm and
228
+ calls it, so the editor types and checks the shape the loader would otherwise only reject at
229
+ boot. The import means the file cannot be evaluated before its dependencies are installed --
230
+ which is why `wattpm install`/`create` install the root's own dependencies before reading it to
231
+ discover the applications (see installDependencies in wattpm-utils).
216
232
  */
217
233
  serializeConfigFile (config) {
218
234
  /*
@@ -225,7 +241,13 @@ export class RuntimeGenerator extends BaseGenerator {
225
241
  // thing, and leaving them would write the placeholder's own text where an expression belongs.
226
242
  const resolved = this.resolveScaffoldedPlaceholders(rest)
227
243
 
228
- return `export default ${serializeConfiguration(resolved)}\n`
244
+ /*
245
+ The root is spelled the same way an application is: a factory call, `defineConfig` from
246
+ wattpm, whose whole job is to type its argument so the editor completes and checks the shape
247
+ the loader would otherwise only reject at boot. wattpm's defineConfig imports nothing, so the
248
+ root evaluates in the eval worker without paying for the runtime it would otherwise pull in.
249
+ */
250
+ return `import { defineConfig } from 'wattpm'\n\nexport default defineConfig(${serializeConfiguration(resolved)})\n`
229
251
  }
230
252
 
231
253
  async _getConfigFileContents () {
@@ -648,6 +670,13 @@ export class WrappedGenerator extends BaseGenerator {
648
670
  async #updateEnvironment () {
649
671
  this.addEnvVars(getRuntimeBaseEnvVars(this.config), { overwrite: false, default: true })
650
672
 
673
+ // A wrapped project is a runtime of one application by construction, so that application is the
674
+ // one that must face the network -- otherwise the wrap would leave the code it was built around
675
+ // reachable from nowhere. The port is registered here so #createConfigFile can read it back as
676
+ // `Number(process.env.PORT || 3042)`; bare PORT rather than a scoped name, because the wrapped
677
+ // application is the root's own singular one and is what a hosting platform's PORT addresses.
678
+ this.addEnvVar('PORT', 3042, { overwrite: false, default: true })
679
+
651
680
  this.addFile({
652
681
  path: '',
653
682
  file: '.env',
@@ -713,11 +742,13 @@ export class WrappedGenerator extends BaseGenerator {
713
742
  wrapped around.
714
743
 
715
744
  Its capability is spelled as a plain object rather than a factory call because this file is
716
- read before anything is installed, and an import cannot be resolved in that state.
745
+ read before anything is installed, and an import cannot be resolved in that state. The bare
746
+ definition sits directly under `application` -- no `config` wrapper -- because the loader wraps
747
+ a definition (a module marker, no entry keys) into the entry's config slot on its own.
717
748
  */
718
749
  const config = {
719
750
  ...this.resolveScaffoldedPlaceholders(getRuntimeWrappableProperties()),
720
- application: { config: { module: this.module } }
751
+ application: this.resolveScaffoldedPlaceholders({ module: this.module, server: { port: '{PORT}' } })
721
752
  }
722
753
 
723
754
  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.7",
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.7",
43
+ "@platformatic/db": "4.0.0-new-config.7",
44
+ "@platformatic/node": "4.0.0-new-config.7",
45
+ "@platformatic/gateway": "4.0.0-new-config.7",
46
+ "@platformatic/service": "4.0.0-new-config.7",
47
+ "@platformatic/sql-graphql": "4.0.0-new-config.7",
48
+ "@platformatic/sql-mapper": "4.0.0-new-config.7",
49
+ "@platformatic/wattpm-pprof-capture": "4.0.0-new-config.7"
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/foundation": "4.0.0-new-config.7",
82
+ "@platformatic/generators": "4.0.0-new-config.7",
83
+ "@platformatic/globals": "4.0.0-new-config.7",
84
+ "@platformatic/itc": "4.0.0-new-config.7",
85
+ "@platformatic/basic": "4.0.0-new-config.7",
86
+ "@platformatic/tracing": "4.0.0-new-config.7",
87
+ "@platformatic/metrics": "4.0.0-new-config.7"
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.7.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",