From 8556aca0356c13410fd7099fd627fbb91d0ac559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20Berkes?= Date: Sat, 11 Apr 2026 01:05:10 +0200 Subject: [PATCH 1/4] Migrate publishing from OSSRH to Maven Central Portal OSSRH (oss.sonatype.org) was sunset on June 30, 2025. This commit modernizes the Gradle build to publish via the new Maven Central Portal (central.sonatype.com). Changes: - Replace legacy 'maven' and 'com.bmuschko.nexus' plugins with 'maven-publish', 'signing', and 'tech.yanand.maven-central-publish' - Upgrade Gradle wrapper from 4.2 to 7.6.4 - Migrate POM metadata from 'modifyPom' block to 'pom { }' DSL - Remove jcenter() and oss.sonatype.org repositories; keep mavenCentral() only - Add javadoc and sources JARs (required by Maven Central) - Preserve the original 4 explicit runtime dependencies in the published POM - Credentials read from Gradle properties (sonatypeUsername/sonatypePassword); never hardcoded - Ignore gradle.properties and .claude/ in git to prevent accidental secret exposure Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + build.gradle | 246 ++++++++++++----------- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 134 insertions(+), 116 deletions(-) diff --git a/.gitignore b/.gitignore index bd0f220..404be72 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /log/ /build/ /.gradle/ +gradle.properties +.claude/ diff --git a/build.gradle b/build.gradle index f6a2d2d..2a0baf6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,17 +1,9 @@ -apply plugin: 'java' -apply plugin: 'maven' -apply plugin: 'com.bmuschko.nexus' -apply plugin: 'jacoco' - -buildscript { - repositories { - maven { - url 'https://plugins.gradle.org/m2/' - } - } - dependencies { - classpath 'com.bmuschko:gradle-nexus-plugin:2.3.1' - } +plugins { + id 'java' + id 'jacoco' + id 'maven-publish' + id 'signing' + id 'tech.yanand.maven-central-publish' version '0.6.0' } // --- JAVADOC --- @@ -19,22 +11,21 @@ buildscript { javadoc { options.encoding = 'UTF-8' failOnError = false - exclude '**/PojoTest.java' + exclude '**/PojoTest.java' } // --- JACOCO --- jacocoTestReport { reports { - xml.enabled true - html.enabled true - } + xml.required = true + html.required = true + } } // --- CONFIGURATIONS --- configurations { - runtime ecj } @@ -46,8 +37,6 @@ configurations.all { repositories { mavenCentral() - jcenter() - maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } } // --- SOURCE --- @@ -72,30 +61,30 @@ sourceSets { // --- DEPENDENCIES --- dependencies { - + // ============== COMPILATION ============== - + testImplementation 'junit:junit:4.12' ecj 'org.eclipse.jdt.core.compiler:ecj:4.4.2' // ================ TESTING ================ // https://mvnrepository.com/artifact/com.openpojo/openpojo - testImplementation group: 'com.openpojo', name: 'openpojo', version: '0.8.10' - + testImplementation group: 'com.openpojo', name: 'openpojo', version: '0.8.10' + // =============== MOLECULER =============== // Moleculer core (required) implementation group: 'com.github.berkesa', name: 'moleculer-java', version: '1.2.28' - + // Moleculer developer console (optional) implementation group: 'com.github.berkesa', name: 'moleculer-java-repl', version: '1.3.1' - + // ================ LOGGING ================ - + // http://mvnrepository.com/artifact/org.slf4j/slf4j-api implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.30' - + // http://mvnrepository.com/artifact/org.slf4j/slf4j-jdk14 implementation group: 'org.slf4j', name: 'slf4j-jdk14', version: '1.7.30' @@ -106,18 +95,18 @@ dependencies { implementation group: 'org.slf4j', name: 'jcl-over-slf4j', version: '1.7.30' // =============== CDI FRAMEWORKS =============== - + // --- SPRING DEPENDENCY INJECTION FRAMEWORK --- // https://mvnrepository.com/artifact/org.springframework/spring-context implementation (group: 'org.springframework', name: 'spring-context', version: '5.3.7') { - exclude group: 'org.springframework', module: 'spring-jcl' + exclude group: 'org.springframework', module: 'spring-jcl' } - + // ============ WEB CONNECTORS ============= - + // --- NETTY SERVER --- - + // https://mvnrepository.com/artifact/io.netty/netty-handler implementation group: 'io.netty', name: 'netty-handler', version: '4.1.65.Final' @@ -125,12 +114,12 @@ dependencies { implementation group: 'io.netty', name: 'netty-codec-http', version: '4.1.65.Final' // --- WEBSOCKET API --- - + // https://mvnrepository.com/artifact/org.java-websocket/Java-WebSocket implementation group: 'org.java-websocket', name: 'Java-WebSocket', version: '1.5.2' - + // ========== ASYNC HTTP CLIENT ============ - + // https://mvnrepository.com/artifact/org.apache.httpcomponents/httpasyncclient implementation group: 'org.apache.httpcomponents', name: 'httpasyncclient', version: '4.1.4' @@ -138,15 +127,15 @@ dependencies { implementation group: 'org.apache.httpcomponents', name: 'httpmime', version: '4.5.13' // ============ JETTY SERVER =============== - + // https://mvnrepository.com/artifact/org.eclipse.jetty.websocket/javax-websocket-server-impl implementation group: 'org.eclipse.jetty.websocket', name: 'javax-websocket-server-impl', version: '9.4.41.v20210516' - + // https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-continuation implementation group: 'org.eclipse.jetty', name: 'jetty-continuation', version: '9.4.41.v20210516' - + // ========== MULTIPART PARSER ============= - + // https://mvnrepository.com/artifact/org.synchronoss.cloud/nio-multipart-parser implementation group: 'org.synchronoss.cloud', name: 'nio-multipart-parser', version: '1.1.0' @@ -156,42 +145,42 @@ dependencies { // https://mvnrepository.com/artifact/com.github.berkesa/datatree-templates implementation group: 'com.github.berkesa', name: 'datatree-templates', version: '1.1.4' - + // --- FREEMARKER --- - + // https://mvnrepository.com/artifact/org.freemarker/freemarker implementation group: 'org.freemarker', name: 'freemarker', version: '2.3.31' - + // --- JADE --- - + // https://mvnrepository.com/artifact/de.neuland-bfi/jade4j implementation group: 'de.neuland-bfi', name: 'jade4j', version: '1.3.2' // --- MUSTACHE --- - + // https://mvnrepository.com/artifact/com.github.spullara.mustache.java/compiler implementation group: 'com.github.spullara.mustache.java', name: 'compiler', version: '0.9.10' // --- THYMELEAF --- - + // https://mvnrepository.com/artifact/org.thymeleaf/thymeleaf implementation group: 'org.thymeleaf', name: 'thymeleaf', version: '3.0.12.RELEASE' // --- PEBBLE --- - + // https://mvnrepository.com/artifact/com.mitchellbosecke/pebble implementation group: 'com.mitchellbosecke', name: 'pebble', version: '2.4.0' // --- HANDLEBARS --- - + // https://mvnrepository.com/artifact/com.github.jknack/handlebars implementation group: 'com.github.jknack', name: 'handlebars', version: '4.2.0' - + // --- VELOCITY --- - + // https://mvnrepository.com/artifact/org.apache.velocity/velocity-engine-core implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3' - + } sourceCompatibility = 1.8 @@ -199,76 +188,103 @@ targetCompatibility = 1.8 group = 'com.github.berkesa' -// version = '1.3.12.1-SNAPSHOT' -version = '1.3.12' -// + JAR version - -modifyPom { - project { - artifactId 'moleculer-java-web' - name 'Java API gateway service for Moleculer' - description 'Non-blocking J2EE and Netty connector with WebSocket and SSL support.' - url 'https://moleculer-java.github.io/moleculer-java-web/' - inceptionYear '2019' - - scm { - url 'https://moleculer-java.github.io/moleculer-java-web/' - connection 'scm:https://github.com/moleculer-java/moleculer-java-web.git' - developerConnection 'scm:git://github.com/moleculer-java/moleculer-java-web.git' - } +// version = '1.3.13-SNAPSHOT' +version = '1.3.13' - licenses { - license { - name 'The MIT License' - url 'http://www.opensource.org/licenses/MIT' - distribution 'repo' - } - } - - dependencies { - dependency { - groupId 'com.github.berkesa' - artifactId 'moleculer-java' - version '1.2.28' - scope 'runtime' - } - dependency { - groupId 'org.synchronoss.cloud' - artifactId 'nio-multipart-parser' - version '1.1.0' - scope 'runtime' - } - dependency { - groupId 'io.netty' - artifactId 'netty-handler' - version '4.1.65.Final' - scope 'runtime' - } - dependency { - groupId 'io.netty' - artifactId 'netty-codec-http' - version '4.1.65.Final' - scope 'runtime' +// --- SOURCES AND JAVADOC JARS (required by Maven Central) --- + +java { + withJavadocJar() + withSourcesJar() +} + +// --- PUBLISHING --- + +publishing { + publications { + mavenJava(MavenPublication) { + artifactId = 'moleculer-java-web' + from components.java + + // Replace auto-generated dependencies with explicit POM dependencies. + // The project has many optional dependencies (template engines, Jetty, + // Spring, etc.) that end-users should pull in themselves. Only the 4 + // required runtime deps are declared in the published POM. + pom.withXml { + def root = asNode() + def existingDeps = root.dependencies + if (existingDeps) { + root.remove(existingDeps[0]) + } + def depsNode = root.appendNode('dependencies') + [ + ['com.github.berkesa', 'moleculer-java', '1.2.28'], + ['org.synchronoss.cloud', 'nio-multipart-parser', '1.1.0'], + ['io.netty', 'netty-handler', '4.1.65.Final'], + ['io.netty', 'netty-codec-http', '4.1.65.Final'], + ].each { groupId, artifactId, version -> + def dep = depsNode.appendNode('dependency') + dep.appendNode('groupId', groupId) + dep.appendNode('artifactId', artifactId) + dep.appendNode('version', version) + dep.appendNode('scope', 'runtime') + } } - } - developers { - developer { - id 'berkesa' - name 'Andras Berkes' - email 'andras.berkes@programmer.net' + pom { + name = 'Java API gateway service for Moleculer' + description = 'Non-blocking J2EE and Netty connector with WebSocket and SSL support.' + url = 'https://moleculer-java.github.io/moleculer-java-web/' + inceptionYear = '2019' + + scm { + url = 'https://moleculer-java.github.io/moleculer-java-web/' + connection = 'scm:https://github.com/moleculer-java/moleculer-java-web.git' + developerConnection = 'scm:git://github.com/moleculer-java/moleculer-java-web.git' + } + + licenses { + license { + name = 'The MIT License' + url = 'http://www.opensource.org/licenses/MIT' + distribution = 'repo' + } + } + + developers { + developer { + id = 'berkesa' + name = 'Andras Berkes' + email = 'andras.berkes@programmer.net' + } + } } } } } -nexus { - sign = true - repositoryUrl = 'https://oss.sonatype.org/service/local/staging/deploy/maven2' - snapshotRepositoryUrl = 'https://oss.sonatype.org/content/repositories/snapshots' +// --- SIGNING --- + +signing { + sign publishing.publications.mavenJava +} + +// --- MAVEN CENTRAL PORTAL --- + +mavenCentralPublish { + // Credentials from ~/.gradle/gradle.properties or -P flags. + // The token is Base64-encoded "username:password". + def user = providers.gradleProperty('sonatypeUsername').getOrNull() + def pass = providers.gradleProperty('sonatypePassword').getOrNull() + if (user != null && pass != null) { + authToken = Base64.encoder.encodeToString("${user}:${pass}".bytes) + } + // 'AUTOMATIC' releases to Maven Central immediately after validation. + // Change to 'USER_MANAGED' to review the deployment in the Portal UI first. + publishingType = 'AUTOMATIC' } -// --- COMPILATION --- +// --- COMPILATION (ECJ) --- compileJava { options.fork = true @@ -281,6 +297,6 @@ compileJava { // --- JAR --- jar { - baseName = 'moleculer-web' - version = '1.3.12' -} \ No newline at end of file + archiveBaseName = 'moleculer-web' + archiveVersion = '1.3.13' +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4fd2076..ba5b117 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-bin.zip From 42791872b1d987a45258c034a11477ca836f6d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20Berkes?= Date: Sat, 11 Apr 2026 01:05:55 +0200 Subject: [PATCH 2/4] Validate HTTP status code range before setting response status Clamp error response status codes to the valid HTTP range (100-599). If MoleculerError returns an out-of-range code, fall back to 500 to prevent invalid HTTP responses. Co-Authored-By: Claude Sonnet 4.6 --- .../java/services/moleculer/web/common/GatewayUtils.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/services/moleculer/web/common/GatewayUtils.java b/src/main/java/services/moleculer/web/common/GatewayUtils.java index 3393a40..d726fdc 100644 --- a/src/main/java/services/moleculer/web/common/GatewayUtils.java +++ b/src/main/java/services/moleculer/web/common/GatewayUtils.java @@ -98,7 +98,11 @@ public static final void sendError(WebResponse rsp, Throwable cause) { } Tree json = error.toTree(); byte[] body = json.toBinary(); - rsp.setStatus(error.getCode()); + int statusCode = error.getCode(); + if (statusCode < 100 || statusCode > 599) { + statusCode = 500; + } + rsp.setStatus(statusCode); rsp.setHeader(CACHE_CONTROL, NO_CACHE); rsp.setHeader(CONTENT_TYPE, CONTENT_TYPE_JSON); rsp.setHeader(CONTENT_LENGTH, Integer.toString(body.length)); From 7f2f22a58cb432832b793e958eb58e9704a09e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20Berkes?= Date: Sat, 11 Apr 2026 01:40:49 +0200 Subject: [PATCH 3/4] Fix Gradle 7.x compatibility and wire up Central Portal upload - Remove nonexistent tech.yanand.maven-central-publish plugin - Add datatree-core and datatree-promise as compileOnly deps (Gradle 7.x correctly excludes runtime-scoped transitive deps from compile classpath) - Add sourcesJar duplicate strategy for favicon.ico conflict - Replace ECJ fork hack (incompatible with Gradle 7.x) with standard javac - Add staging repository and publishToMavenCentralPortal task that reads credentials from .gradle/secrets.xml and uploads via Central Portal REST API --- build.gradle | 88 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/build.gradle b/build.gradle index 2a0baf6..19e30a2 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,6 @@ plugins { id 'jacoco' id 'maven-publish' id 'signing' - id 'tech.yanand.maven-central-publish' version '0.6.0' } // --- JAVADOC --- @@ -80,6 +79,12 @@ dependencies { // Moleculer developer console (optional) implementation group: 'com.github.berkesa', name: 'moleculer-java-repl', version: '1.3.1' + // datatree-* is declared as 'runtime' scope in moleculer-java's POM, so + // Gradle 7.x correctly excludes it from the compile classpath. We add it + // explicitly so the io.datatree API is available when compiling this module. + compileOnly group: 'com.github.berkesa', name: 'datatree-core', version: '1.1.2' + compileOnly group: 'com.github.berkesa', name: 'datatree-promise', version: '1.0.10' + // ================ LOGGING ================ // http://mvnrepository.com/artifact/org.slf4j/slf4j-api @@ -198,6 +203,12 @@ java { withSourcesJar() } +// The main source set includes *.ico as resources from src/main/java, causing +// duplicates when building the sources JAR (same file via java + resources srcDir). +sourcesJar { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + // --- PUBLISHING --- publishing { @@ -261,6 +272,14 @@ publishing { } } } + + // Local staging repository — artifacts are assembled here before bundling. + repositories { + maven { + name = 'staging' + url = layout.buildDirectory.dir('staging-deploy') + } + } } // --- SIGNING --- @@ -269,31 +288,58 @@ signing { sign publishing.publications.mavenJava } -// --- MAVEN CENTRAL PORTAL --- - -mavenCentralPublish { - // Credentials from ~/.gradle/gradle.properties or -P flags. - // The token is Base64-encoded "username:password". - def user = providers.gradleProperty('sonatypeUsername').getOrNull() - def pass = providers.gradleProperty('sonatypePassword').getOrNull() - if (user != null && pass != null) { - authToken = Base64.encoder.encodeToString("${user}:${pass}".bytes) - } - // 'AUTOMATIC' releases to Maven Central immediately after validation. - // Change to 'USER_MANAGED' to review the deployment in the Portal UI first. - publishingType = 'AUTOMATIC' +// --- MAVEN CENTRAL PORTAL UPLOAD --- +// +// Usage: ./gradlew publishToMavenCentralPortal +// +// Requires in ~/.gradle/gradle.properties: +// sonatypeUsername= +// sonatypePassword= + +tasks.register('bundleStagingRepo', Zip) { + dependsOn 'publishAllPublicationsToStagingRepository' + description = 'Bundles the local staging repository into a ZIP for Central Portal upload.' + archiveFileName = "bundle-${project.version}.zip" + destinationDirectory = layout.buildDirectory.dir('bundle') + from layout.buildDirectory.dir('staging-deploy') } -// --- COMPILATION (ECJ) --- +tasks.register('publishToMavenCentralPortal') { + dependsOn 'bundleStagingRepo' + description = 'Uploads the signed artifact bundle to the Maven Central Portal.' + group = 'publishing' + doLast { + def secretsFile = file('.gradle/secrets.xml') + if (!secretsFile.exists()) { + throw new GradleException("Credentials file not found: ${secretsFile.absolutePath}") + } + def xml = new XmlSlurper().parse(secretsFile) + def user = xml.username.text() + def pass = xml.password.text() + if (!user || !pass) { + throw new GradleException("Could not read username/password from ${secretsFile.absolutePath}") + } + def token = Base64.encoder.encodeToString("${user}:${pass}".bytes) + def bundleFile = bundleStagingRepo.archiveFile.get().asFile -compileJava { - options.fork = true - options.forkOptions.with { - executable = 'java' - jvmArgs = ['-classpath', project.configurations.ecj.asPath, 'org.eclipse.jdt.internal.compiler.batch.Main', '-nowarn'] - } + println "Uploading ${bundleFile.name} (${bundleFile.length()} bytes) to Maven Central Portal..." + + exec { + commandLine 'curl', + '--fail', '--silent', '--show-error', '--location', + '-H', "Authorization: Bearer ${token}", + '-F', "bundle=@${bundleFile.absolutePath}", + 'https://central.sonatype.com/api/v1/publisher/upload?publishingType=AUTOMATIC' + } + + println "Upload complete. Check https://central.sonatype.com/publishing/deployments for status." + } } +// --- COMPILATION --- +// Standard javac is used. The ECJ dependency is kept for reference but the +// old fork-based ECJ hack is not compatible with Gradle 7.x's compilation daemon. + // --- JAR --- jar { From 38029df194169597677715a0c5424c6b0e1df1e7 Mon Sep 17 00:00:00 2001 From: Icebob Date: Wed, 3 Jun 2026 10:23:56 +0200 Subject: [PATCH 4/4] Keep connection alive for body-less responses (204) instead of closing it NettyWebResponse.end() closed the channel whenever no Content-Length header was present. This is the case for body-less responses such as "204 No Content" (or an action returning null). The close was performed without a "Connection: close" header, so HTTP clients that use keep-alive connection pools (browsers, Node's http.Agent / axios, Java HttpClient, ...) returned the socket to the pool and hit "ECONNRESET" / "socket hang up" on the next request that reused it. For a body-less response the message length is well-defined (zero), so emit an explicit "Content-Length: 0" before the headers are flushed. The existing logic then sees a Content-Length, keeps the connection alive, and the response is correctly framed. Responses that write a body are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../moleculer/web/netty/NettyWebResponse.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/services/moleculer/web/netty/NettyWebResponse.java b/src/main/java/services/moleculer/web/netty/NettyWebResponse.java index e403b01..d58e6ca 100644 --- a/src/main/java/services/moleculer/web/netty/NettyWebResponse.java +++ b/src/main/java/services/moleculer/web/netty/NettyWebResponse.java @@ -159,6 +159,20 @@ public void send(byte[] bytes) throws IOException { */ @Override public boolean end() { + + // If no response body has been written (e.g. "204 No Content", or an + // action that returned null), the response is complete with zero + // length. Emit an explicit "Content-Length: 0" so the message is + // properly framed and the keep-alive connection can be reused. Without + // it, the block below closes the channel (no Content-Length is present) + // but no "Connection: close" header is sent, so pooled HTTP clients + // (browsers, Node http.Agent/axios, ...) reuse the socket and fail the + // next request with "ECONNRESET" / "socket hang up". + if (first.get() && (req == null || req.parser == null) + && (headers == null || headers.get(CONTENT_LENGTH) == null)) { + setHeader(CONTENT_LENGTH, "0"); + } + sendHeaders(); if (req != null && req.parser != null) { try {