diff --git a/.github/workflows/blog-prose.yml b/.github/workflows/blog-prose.yml
index b287e14b353..a0da186cfa7 100644
--- a/.github/workflows/blog-prose.yml
+++ b/.github/workflows/blog-prose.yml
@@ -131,6 +131,11 @@ jobs:
cd scripts
npm init -y 2>/dev/null || true
npm install playwright
+ # Playwright shells out to apt for the OS-level deps, so the runner
+ # image's third-party sources have to be pruned FIRST -- a broken
+ # vendor mirror makes its apt-get fail as a whole and it reports
+ # only "Failed to install browser dependencies".
+ bash "$GITHUB_WORKSPACE/scripts/ci/apt-get-update.sh"
npx playwright install-deps chromium
npx playwright install chromium
diff --git a/.github/workflows/blog-syndication.yml b/.github/workflows/blog-syndication.yml
index 9e0a00213fa..762b852cdbb 100644
--- a/.github/workflows/blog-syndication.yml
+++ b/.github/workflows/blog-syndication.yml
@@ -79,6 +79,11 @@ jobs:
if: ${{ steps.browser_creds.outputs.any_configured == 'true' }}
run: |
set -euo pipefail
+ # Playwright shells out to apt for the OS-level deps, so the runner
+ # image's third-party sources have to be pruned FIRST -- a broken
+ # vendor mirror makes its apt-get fail as a whole and it reports
+ # only "Failed to install browser dependencies".
+ bash "$GITHUB_WORKSPACE/scripts/ci/apt-get-update.sh"
pip install playwright
playwright install --with-deps chromium
diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml
index 8bb420da735..ecaf1d51175 100644
--- a/.github/workflows/developer-guide-docs.yml
+++ b/.github/workflows/developer-guide-docs.yml
@@ -133,7 +133,7 @@ jobs:
run: |
set -euo pipefail
xvfb-run -a mvn -B -ntp -f maven/pom.xml \
- -pl core,javase,android,css-compiler,codenameone-maven-plugin \
+ -pl core,javase,android,css-compiler,codenameone-maven-plugin,backend \
-am install \
-Plocal-dev-javase \
-DskipTests \
diff --git a/.github/workflows/port-status-nightly.yml b/.github/workflows/port-status-nightly.yml
index 56036a4b261..7cd0bdc75e3 100644
--- a/.github/workflows/port-status-nightly.yml
+++ b/.github/workflows/port-status-nightly.yml
@@ -82,6 +82,11 @@ jobs:
run: |
npm init -y 2>/dev/null || true
npm install playwright
+ # Playwright shells out to apt for the OS-level deps, so the runner
+ # image's third-party sources have to be pruned FIRST -- a broken
+ # vendor mirror makes its apt-get fail as a whole and it reports
+ # only "Failed to install browser dependencies".
+ bash "$GITHUB_WORKSPACE/scripts/ci/apt-get-update.sh"
npx playwright install --with-deps "${{ matrix.browser }}"
- name: Run lifecycle validation
env:
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index cf0e7d99fb8..c677824c720 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -27,6 +27,7 @@ on:
- 'scripts/ci/retry.sh'
- 'scripts/ci/apt-get-update.sh'
- 'scripts/ci/apt-get-install.sh'
+ - 'scripts/ci/tests/apt-get-update-test.sh'
# The build hint gates are run from this workflow and nowhere else, and one
# of them holds an empty baseline. Ignoring the whole directory meant a
# change that breaks a gate, or that adds a line to the baseline, could
@@ -78,6 +79,7 @@ on:
- 'scripts/ci/retry.sh'
- 'scripts/ci/apt-get-update.sh'
- 'scripts/ci/apt-get-install.sh'
+ - 'scripts/ci/tests/apt-get-update-test.sh'
# The build hint gates are run from this workflow and nowhere else, and one
# of them holds an empty baseline. Ignoring the whole directory meant a
# change that breaks a gate, or that adds a line to the baseline, could
@@ -469,6 +471,12 @@ jobs:
- name: Check build hint catalog
if: ${{ matrix.java-version == 8 }}
run: scripts/check-build-hint-catalog.sh
+ - name: Check the apt source prune keeps Ubuntu's own sources
+ if: ${{ matrix.java-version == 8 }}
+ # Cheap, and the rule it covers is only ever exercised on a runner whose
+ # apt is already broken -- so without this it would be tested by a red
+ # build and nothing else.
+ run: bash scripts/ci/tests/apt-get-update-test.sh
- name: Check the build hint data file can be rendered
if: ${{ matrix.java-version == 8 }}
# Not a drift check: nothing is committed to drift from, because every
diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml
index 1b38763644b..4f999727ff0 100644
--- a/.github/workflows/scripts-javascript.yml
+++ b/.github/workflows/scripts-javascript.yml
@@ -210,6 +210,11 @@ jobs:
npm init -y 2>/dev/null || true
npm install playwright
# OS-level deps (apt packages) aren't cached so always install them.
+ # Playwright shells out to apt for the OS-level deps, so the runner
+ # image's third-party sources have to be pruned FIRST -- a broken
+ # vendor mirror makes its apt-get fail as a whole and it reports
+ # only "Failed to install browser dependencies".
+ bash "$GITHUB_WORKSPACE/scripts/ci/apt-get-update.sh"
npx playwright install-deps chromium
# `npm install playwright` resolves the floating version, so the
# cached browser binary can drift away from what the freshly
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
index 7c81f0785eb..b4411c4fa8e 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -9912,8 +9912,18 @@ public void onReceive(Context ctx, Intent intent) {
try { appCtx.unregisterReceiver(this); } catch (Throwable ignore) {}
String pkg = null;
try {
- android.content.ComponentName cn = intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT);
- if (cn != null) pkg = cn.getPackageName();
+ // Taken as a Parcelable and tested, rather than assigned straight
+ // to ComponentName: that assignment compiles to a CHECKCAST whose
+ // failure this catch would have to handle, and the extra is
+ // whatever the SENDING application chose to put there, so the
+ // failure is not hypothetical. (The cast-semantics gate no longer
+ // scans this port, since ParparVM does not translate it -- this
+ // stands on its own terms.)
+ android.os.Parcelable chosen =
+ intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT);
+ if (chosen instanceof android.content.ComponentName) {
+ pkg = ((android.content.ComponentName) chosen).getPackageName();
+ }
} catch (Throwable ignore) {}
listener.onResult(com.codename1.share.ShareResult.sharedTo(pkg));
}
diff --git a/docs/demos/backend/pom.xml b/docs/demos/backend/pom.xml
new file mode 100644
index 00000000000..74f7cea27de
--- /dev/null
+++ b/docs/demos/backend/pom.xml
@@ -0,0 +1,36 @@
+
+
+ 4.0.0
+
+ com.codenameone.developerguide
+ democode
+ 1.0-SNAPSHOT
+
+ backendsnippets
+ backendsnippets
+
+
+
+ com.codenameone
+ codenameone-backend
+ ${cn1.version}
+
+
+
+ com.codenameone
+ codenameone-core
+ ${cn1.version}
+
+
+
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java
new file mode 100644
index 00000000000..7e58bf071b9
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend;
+
+import com.codename1.backend.Database;
+import java.io.IOException;
+import java.util.List;
+
+/** The Backend chapter's database examples, compiled so they cannot drift. */
+public final class DatabaseSnippets {
+
+ private DatabaseSnippets() {
+ }
+
+ public static List open() throws IOException {
+// tag::backend-database[]
+Database db = Database.open(System.getenv("DATABASE_URL")); // or ":memory:"
+db.execute("CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT)",
+ null);
+
+List rows = db.query("SELECT id, body FROM note WHERE id > ?",
+ new Object[] { Integer.valueOf(10) });
+// end::backend-database[]
+ return rows;
+ }
+}
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Note.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Note.java
new file mode 100644
index 00000000000..009b4e8c340
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Note.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend;
+
+/** The data transfer object both ends of {@link NotesApi} share. */
+public class Note {
+ public long id;
+ public String body;
+
+ public Note() {
+ }
+}
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Notes.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Notes.java
new file mode 100644
index 00000000000..a5c1f9edbc6
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Notes.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend;
+
+import com.codename1.backend.annotations.DeleteMapping;
+import com.codename1.backend.annotations.GetMapping;
+import com.codename1.backend.annotations.PathVariable;
+import com.codename1.backend.annotations.PostMapping;
+import com.codename1.backend.annotations.RequestBody;
+import com.codename1.backend.annotations.RequestMapping;
+import com.codename1.backend.annotations.RequestParam;
+import com.codename1.backend.annotations.ResponseStatus;
+import com.codename1.backend.annotations.RestController;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+// tag::backend-first-server[]
+@RestController
+@RequestMapping("/notes")
+public class Notes {
+ private final Map store = new ConcurrentHashMap();
+ private final AtomicLong nextId = new AtomicLong(1);
+
+ @GetMapping("/healthz")
+ public String health() {
+ return "ok";
+ }
+
+ @GetMapping("/{id}")
+ public Map read(@PathVariable("id") long id) {
+ return store.get(Long.valueOf(id)); // null becomes a 404
+ }
+
+ @GetMapping
+ public List list(@RequestParam(value = "limit", defaultValue = "20") int limit) {
+ List page = new ArrayList();
+ for (Map note : store.values()) {
+ if (page.size() >= limit) {
+ break;
+ }
+ page.add(note);
+ }
+ return page;
+ }
+
+ @PostMapping
+ @ResponseStatus(201)
+ public Map create(@RequestBody Map note) {
+ long id = nextId.getAndIncrement();
+ Map stored = new LinkedHashMap(note);
+ stored.put("id", Long.valueOf(id));
+ store.put(Long.valueOf(id), stored);
+ return stored;
+ }
+
+ @DeleteMapping("/{id}")
+ @ResponseStatus(204)
+ public void delete(@PathVariable("id") long id) {
+ store.remove(Long.valueOf(id));
+ }
+}
+// end::backend-first-server[]
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApi.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApi.java
new file mode 100644
index 00000000000..a8bab066763
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApi.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend;
+
+import com.codename1.annotations.rest.Body;
+import com.codename1.annotations.rest.GET;
+import com.codename1.annotations.rest.POST;
+import com.codename1.annotations.rest.Path;
+import com.codename1.annotations.rest.RestClient;
+import com.codename1.io.rest.Response;
+import com.codename1.util.OnComplete;
+
+// tag::backend-contract[]
+@RestClient
+public interface NotesApi {
+ @GET("/notes/{id}")
+ void note(@Path("id") String id, OnComplete> callback);
+
+ @POST("/notes")
+ void create(@Body Note note, OnComplete> callback);
+}
+// end::backend-contract[]
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApiServer.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApiServer.java
new file mode 100644
index 00000000000..53b94ec215d
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApiServer.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend;
+
+/**
+ * What the build emits from {@link NotesApi} for the server side.
+ *
+ * Reproduced here rather than generated: the generator runs after this module
+ * compiles, and the point of these files is that the guide's examples compile at
+ * all. A real project never writes this -- it comes out of the same annotated
+ * contract the app's client comes out of, method for method.
+ */
+interface NotesApiServer {
+ Note note(String id) throws Exception;
+
+ Note create(Note note) throws Exception;
+}
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java
new file mode 100644
index 00000000000..fd79b067cac
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+// tag::backend-contract-server[]
+public class NotesEndpoint implements NotesApiServer {
+ private final Map notes = new ConcurrentHashMap();
+
+ public Note note(String id) { // no callback: this IS the server
+ return notes.get(id);
+ }
+
+ public Note create(Note note) {
+ notes.put(String.valueOf(note.id), note);
+ return note;
+ }
+}
+// end::backend-contract-server[]
diff --git a/docs/demos/pom.xml b/docs/demos/pom.xml
index 0bc4923fbd4..c7200018e26 100644
--- a/docs/demos/pom.xml
+++ b/docs/demos/pom.xml
@@ -17,6 +17,7 @@
common
+backend8.0-SNAPSHOT
diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc
new file mode 100644
index 00000000000..dea7664dd5f
--- /dev/null
+++ b/docs/developer-guide/Backend.asciidoc
@@ -0,0 +1,340 @@
+== Server-side backend
+
+The backend compiles a Java HTTP handler into a native executable. It uses the
+same ParparVM pipeline as the iOS, Windows and Linux ports: javac produces
+bytecode, ParparVM turns that into C, and clang compiles the C together with the
+runtime into one binary. Nothing interprets anything at run time, and there is no
+JVM underneath.
+
+The measurements in this chapter come from `vm/backend/benchmarks` on two pinned
+cores against 64 connections, and the harness is in the repository so you can
+disagree with them.
+
+.The build pipeline and the request path
+image::img/backend-architecture.svg[Java compiled to C to a native binary, and a request travelling through a host thread and a virtual thread to the handler,scaledwidth=95%]
+
+=== What this doesn't replace
+
+Spring Boot, Quarkus, Micronaut and Jakarta EE aren't the competition here.
+They carry dependency injection, an ORM, declarative transactions, a security
+stack and two decades of operational knowledge, and none of that exists in this
+runtime. If you are running a Spring service today and it works, this chapter is
+not asking you to move it.
+
+What those frameworks assume is a JVM, and that paying for one is reasonable. For
+most server work that assumption holds. This exists for the work where it fails.
+
+=== Why it exists
+
+A JVM charges you twice. It charges start-up on every cold process, and it
+charges a baseline heap for as long as the process lives. Both are fine when a
+service runs for weeks and handles millions of requests. Neither is fine when the
+process exits after 200 milliseconds, when you are billed per invocation, or when
+the instance is capped at 128 MB.
+
+That describes a specific and growing slice of server work: serverless functions,
+sidecars, edge workers, webhook receivers, small always-on services. Java is thin
+on the ground there, and the reason is arithmetic rather than taste. Teams
+therefore reach for Go or Node, and a Java shop that does this ends up with two
+languages, two toolchains, and two definitions of every object that crosses the
+wire.
+
+The point of the backend is to remove the reason to leave Java for that slice. It
+doesn't try to take work the JVM already does well.
+
+=== A first server
+
+The archetype and the initializr both generate a `backend` module beside the
+client ones:
+
+----
+myapp/
+ common/ shared app code
+ javase/ desktop build
+ ios/ iOS build
+ android/ Android build
+ backend/ the server
+ pom.xml
+ src/main/java/com/example/myapp/Notes.java
+----
+
+A server is a class with routes on it. The annotations are Spring's, under
+Codename One's package names, so this reads the same way to anyone who has
+written a Spring controller:
+
+[source,java]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Notes.java[tag=backend-first-server,indent=0]
+----
+
+There is no `main` here, and that's the point. Every server opens with the same
+twenty lines -- start a listener, install a shutdown handler, wait on it -- and
+getting any of them wrong produces one that leaks connections on SIGTERM, or one
+that ends the moment `main` returns and says nothing about why. The build writes
+those lines, from the controllers it finds.
+
+It also writes the router. The obvious hand-written form,
+`if ("/healthz".equals(request.getTarget()))`, builds a String for the target,
+hashes it and compares it -- for every route it tries before the one that matches,
+on every request -- and breaks as soon as a client appends `?probe=1`. The
+generated router holds each route as a `byte[]` and asks the request whether its
+path bytes are those bytes, so a route with no path variables allocates nothing at
+all and a query string can't break it.
+
+The return value decides the response. A `String` is sent as text, anything else as
+JSON, `null` is a 404, and `@ResponseStatus` sets the code for the cases where 200
+isn't it. A handler that needs something this doesn't model takes the
+`HttpServer.Request` itself and answers exactly as it would have before.
+
+Two commands matter:
+
+----
+# the property is not optional: the backend module lives in a profile, and
+# without it Maven cannot see it in the reactor at all
+mvn -pl backend -Dcodename1.platform=backend cn1:backend # run it on this JVM
+mvn -pl backend -Dcodename1.platform=backend cn1:backend-package # build the native binary
+----
+
+The first is the development loop. Both run the same protocol code -- there is one
+copy of `HttpServer`, and only the layer underneath it differs -- so behaviour
+can't drift between the loop you develop in and the binary you ship. The local
+run doesn't terminate TLS, by design, and therefore doesn't serve HTTP/2:
+both refuse with a message that says so, because a second TLS implementation
+would have its own bugs rather than production's.
+
+=== Virtual threads and the request loop
+
+The concurrency model is the part most worth understanding, because it's what
+makes a handler that blocks acceptable.
+
+Every connection gets a virtual thread. Not a pooled worker that a connection
+borrows for one request -- a stack that belongs to that connection until it
+closes. The handler can block on a socket read, a database round trip or a file,
+and it costs a parked stack rather than an OS thread.
+
+Host threads run those virtual threads, and there is one host per core. A
+descriptor is registered in exactly one host's epoll set, so it can only ever be
+reported to that host, and only that host ever touches its virtual thread. That
+affinity is why the scheduler needs no locks on the hot path.
+
+The loop is: the host polls, a descriptor becomes readable, the host resumes that
+connection's virtual thread, the handler runs until it needs bytes that haven't
+arrived, and it parks. Parking returns control to the host, which polls again.
+When the handler finishes a response the descriptor stays armed, so the next
+request on that connection costs no system call to set up.
+
+Host count follows cores rather than the `workers` argument. On two pinned cores,
+16 hosts served 117 requests where 2 hosts served 257,297: past one host per core
+they compete for the cores the server needs. In this mode `workers` stops meaning
+"requests in flight," because the virtual threads supply that.
+
+=== Talking to a database
+
+`Database.open` takes a SQLite path or a PostgreSQL or MySQL URL, and the rows
+come back as the same Java types either way:
+
+[source,java]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database,indent=0]
+----
+
+There is no JDBC driver involved. SQLite is linked into the binary, and the
+PostgreSQL and MySQL clients speak their wire protocols directly.
+
+`DbPool` pools SQLite connections, and only those: it opens them from a file
+path. There is no pool for the server engines yet. A `Database` over PostgreSQL
+or MySQL owns one wire connection and doesn't serialize access to it, so two
+handlers sharing one interleave their prepared-statement exchanges on the same
+socket. Give each request its own connection until there is a pool for them.
+
+=== Sharing the contract with the app
+
+This is where having the same language on both ends stops being a slogan. An
+interface annotated for the REST client generates the app's client:
+
+[source,java]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApi.java[tag=backend-contract,indent=0]
+----
+
+Building the backend module with `-Dcn1.restServer=true` generates two more types
+from that same interface: `NotesApiServer`, a synchronous interface the backend
+implements, and `NotesApiDispatcher`, which routes a method, path and body to it
+and binds the path and query parameters.
+
+[source,java]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java[tag=backend-contract-server,indent=0]
+----
+
+The client's methods are asynchronous because a UI can't block; the server's
+methods are synchronous because a handler has nothing to call back into. One declaration
+produces both shapes, which is what gRPC does and for the same reason.
+
+The payoff is that changing the contract breaks the build on whichever side did
+not follow it, instead of producing a response the app fails to parse in the
+field. The data transfer objects are shared rather than transcribed, and their
+codecs are generated on both sides, so there is no handwritten mapping layer to
+drift.
+
+The server half is off by default. Every existing project carries these
+interfaces for its client alone, and generating server classes into those builds
+would grow them for nothing.
+
+=== What it costs
+
+Same handler, three ways, plus Go for an outside reference. Two pinned cores, 64
+connections, medians of three interleaved runs on the plaintext route:
+
+[cols="2,1,1,1,1"]
+|===
+| Runtime | Requests/sec | p50 | p99 | Cold start
+
+| Codename One native, musl
+| 595,610
+| 0.090 ms
+| 0.249 ms
+| 0.77 ms
+
+| Codename One native, glibc
+| 547,761
+| 0.065 ms
+| 4.06 ms
+| 2.88 ms
+
+| Go, fasthttp
+| 496,293
+| 0.104 ms
+| 2.63 ms
+| 2.39 ms
+
+| The same handler on the JVM
+| 187,745
+| 0.260 ms
+| 1.60 ms
+| 82.5 ms
+|===
+
+Cold start is the interesting column. It's measured from process spawn to the
+first accepted connection, and the static binary reaches it in under a
+millisecond -- about a hundred times faster than the same code on a JVM, and
+three times faster than Go. That number is the whole serverless argument.
+
+Throughput is the least interesting one. Beating a tuned Go server by a fifth on
+a microbenchmark isn't a reason to move a service; it's only evidence that the
+translation doesn't cost you anything.
+
+.Latency at the median against the 99th percentile
+image::img/backend-latency-slope.svg[A slope chart showing that the musl build's tail stays close to its median while the others fan out,scaledwidth=90%]
+
+The slope chart is the one that matters. Every runtime here has a similar
+median. What differs is the distance to the 99th percentile, and that distance is
+garbage collection. With the response pooled the plaintext route allocates about
+0.1 bytes per request, the collector never runs, and the tail stays at 2.8 times
+the median. Give the same server a handler that allocates a map per request and
+its tail goes to 80 ms, because the collector shares the cores with the server.
+
+The honest rule is that the tail follows your allocation rate, not the runtime
+badge. The runtime gives you the tools to allocate nothing on the hot path; it
+doesn't do it for you.
+
+==== Memory and size
+
+[cols="2,1,1"]
+|===
+| Runtime | Binary or artifact | Resident under load
+
+| Codename One native, musl
+| 7.95 MB static
+| 10-40 MB
+
+| Codename One native, glibc
+| 3.19 MB dynamic
+| 14 MB
+
+| Go, fasthttp
+| 5.63 MB static
+| 6.3 MB
+
+| The same handler on the JVM
+| 0.13 MB jar, plus a JRE
+| 190 MB
+|===
+
+The JVM row is the same handler and the same protocol code. Everything it costs
+above the native rows is the runtime underneath it.
+
+The resident figures move around more than the latency ones, because the
+collector keeps a pool of pages sized to the busiest moment the process has seen
+and gives them back gradually. At rest the native builds sit near 3 MB.
+
+==== musl or glibc
+
+Both are supported, and they aren't equivalent. The static musl build starts
+faster and has a far shorter tail. The glibc build has a better median, because
+its allocator is better under contention, and a smaller binary, because it links
+the system libraries instead of carrying them.
+
+The reason to pick musl isn't the median. It's that the artifact is one file
+with nothing underneath it, which is what makes the container the binary and the
+cold start a process exec.
+
+==== What isn't measured here
+
+GraalVM is missing from these tables on purpose. A fair comparison would have to
+run the same handler, and this handler can't run on GraalVM: the runtime's
+native methods are ParparVM's, so comparing would mean benchmarking a different
+server written against a different framework and reporting it as though the
+toolchains had been compared. That's a benchmark worth building, and it isn't
+this one.
+
+=== Deploying it
+
+The musl build is a single static file, so the container that carries it can be
+empty:
+
+----
+FROM scratch
+COPY bench-linux-musl-arm64 /server
+ENTRYPOINT ["/server"]
+----
+
+There is no base image to patch, because there is no base image. `cn1:backend-package`
+builds for the machine it runs on; the cross-compiled targets
+(`musl-x86_64`, `musl-arm64`, `glibc-x86_64`, `glibc-arm64`) are produced by
+`vm/backend/package.sh` in the Codename One repository, which drives one builder
+image per target.
+
+For AWS Lambda, `LambdaRuntime` implements the custom runtime loop. The Lambda
+Runtime API is a plaintext poll over loopback, so it needs no listening socket and
+no TLS, and what it does need is exactly what a translated binary is good at.
+
+=== Limits worth knowing
+
+* The class library is the Codename One runtime, not Java SE. A server dependency
+ that assumes the full JDK won't translate, and Maven Central isn't the
+ ecosystem this draws on.
+* Routing is the whole of the framework. There is no dependency injection, no
+ configuration model, no aspect layer, no starter ecosystem; validation and error
+ mapping are written by hand or generated from the REST contract.
+* The packaging goal compiles Java. It recompiles the module's sources against the
+ backend's class library instead of reusing the jar Maven built, which is what
+ keeps a server off classes the runtime doesn't have, and is also why Kotlin is
+ not wired into this path yet even though the client ports support it.
+* A virtual thread parks on the socket it's SERVING, and not on any other. An
+ outbound read -- a database query, an HTTP call to another service -- blocks the
+ host thread that's running it, because only the server's own descriptors are
+ registered with a poller that can wake them. With one host per core, that many
+ concurrent slow outbound calls occupy every host and other connections wait.
+ Servers that mostly compute and serve get the full benefit; servers whose
+ handlers spend their time waiting on a database should size for that, or run the
+ thread pool with `CN1_HTTP_POLL_MODE=0`.
+* TLS runs on the thread pool, whatever the poll mode says. The TLS layer can't
+ park a read yet, and a blocking read on a virtual thread holds its host for the
+ duration, so one idle TLS client per core would occupy every one of them. The
+ server says so at startup when it makes that choice.
+* Native builds target Linux. Development happens anywhere a JVM runs.
+
+The reasons to choose this are cold start, footprint, deployment shape, and one
+language across the app and its server. If none of those matter for the service in
+front of you, use a JVM framework.
diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc
index 1305f7cca88..32fec7f8d26 100644
--- a/docs/developer-guide/developer-guide.asciidoc
+++ b/docs/developer-guide/developer-guide.asciidoc
@@ -119,6 +119,10 @@ include::Video-IO.asciidoc[]
include::Video-Capture-Constraints.asciidoc[]
+= Server side
+
+include::Backend.asciidoc[]
+
= Device and platform services
include::Push-Notifications.asciidoc[]
diff --git a/docs/developer-guide/img/backend-architecture.svg b/docs/developer-guide/img/backend-architecture.svg
new file mode 100644
index 00000000000..e438560dbe6
--- /dev/null
+++ b/docs/developer-guide/img/backend-architecture.svg
@@ -0,0 +1,54 @@
+
diff --git a/docs/developer-guide/img/backend-latency-slope.svg b/docs/developer-guide/img/backend-latency-slope.svg
new file mode 100644
index 00000000000..d0716820447
--- /dev/null
+++ b/docs/developer-guide/img/backend-latency-slope.svg
@@ -0,0 +1,42 @@
+
diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt
index 5b9d2aeaa75..40bd88ca353 100644
--- a/docs/developer-guide/languagetool-accept.txt
+++ b/docs/developer-guide/languagetool-accept.txt
@@ -743,3 +743,18 @@ Bodymovin
# A point in an animation where a property's value is pinned, with the values in
# between interpolated. The universal term across every animation toolchain.
keyframes?
+
+# -----------------------------------------------------------------------------
+# Server-side backend (Backend.asciidoc).
+# -----------------------------------------------------------------------------
+# Two of the JVM server frameworks the backend chapter positions itself against.
+# Product names, so the dictionary has neither.
+Quarkus
+Micronaut
+# A service that rescales images, in the chapter's list of small edge-shaped
+# services the backend suits. Ordinary English formation the dictionary lacks.
+resizer
+# The Linux readiness-notification interface the request loop is built on.
+epoll
+# A benchmark of one narrow operation, as against a whole application.
+microbenchmark
diff --git a/maven/backend/pom.xml b/maven/backend/pom.xml
new file mode 100644
index 00000000000..c1fbaf2cad3
--- /dev/null
+++ b/maven/backend/pom.xml
@@ -0,0 +1,132 @@
+
+
+
+
+
+ com.codenameone
+ codenameone
+ 8.0-SNAPSHOT
+
+ 4.0.0
+
+ codenameone-backend
+ jar
+ Codename One Backend Runtime
+
+
+ UTF-8
+ 1.8
+ 1.8
+ ${project.basedir}/../../vm/backend
+
+
+
+
+
+ org.xerial
+ sqlite-jdbc
+ 3.46.1.0
+ true
+
+
+
+
+ ${backend.dir}/src
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+
+
+ add-javase-implementation
+ generate-sources
+
+ add-source
+
+
+
+ ${backend.dir}/impl/javase
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+
+
+ parparvm-sources-jar
+ package
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+
+
+ attach-parparvm-sources
+ package
+
+ attach-artifact
+
+
+
+
+ ${project.build.directory}/${project.build.finalName}-parparvm-sources.jar
+ jar
+ parparvm-sources
+
+
+
+
+
+
+
+
+
diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
index fb723f58259..259093456c5 100644
--- a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
@@ -261,5 +261,15 @@
+
+
+
+ src/main/java
+
+ **/*.java
+
+
+
+
diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml
new file mode 100644
index 00000000000..352afcb18e8
--- /dev/null
+++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml
@@ -0,0 +1,87 @@
+
+
+ 4.0.0
+
+ ${groupId}
+ ${rootArtifactId}
+ ${version}
+
+ ${groupId}
+ ${artifactId}
+ ${version}
+ jar
+
+ ${artifactId}
+
+
+
+
+ com.codenameone
+ codenameone-backend
+ ${cn1.version}
+
+
+
+ org.xerial
+ sqlite-jdbc
+ 3.46.1.0
+ runtime
+
+
+
+
+
+
+ com.codenameone
+ codenameone-maven-plugin
+ ${cn1.plugin.version}
+
+
+
+ cn1-process-annotations
+ process-classes
+
+
+ process-annotations
+
+
+
+
+
+
+
diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java
new file mode 100644
index 00000000000..9a2ed41349d
--- /dev/null
+++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package ${package};
+
+import com.codename1.backend.annotations.GetMapping;
+import com.codename1.backend.annotations.RequestParam;
+import com.codename1.backend.annotations.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * The server side of this app.
+ *
+ * Routes are methods. The annotations are Spring's, under Codename One's package
+ * names, and the build turns them into a router that matches on the request's own
+ * bytes plus the `main` that serves them -- so there is no server lifecycle to
+ * write here and no route table to keep in step by hand.
+ *
+ * While developing, run it with
+ *
+ * mvn -pl backend -Dcodename1.platform=backend cn1:backend
+ *
+ * which starts on this JVM in a couple of seconds against the minute and a half a
+ * native build takes, and whose protocol layer is the same source that ships. The
+ * property is not optional: the backend module lives in a profile, so without it
+ * Maven cannot see it in the reactor. Package it with
+ *
+ * mvn -pl backend -Dcodename1.platform=backend cn1:backend-package
+ *
+ * to get a single native binary with no JVM to install beneath it.
+ *
+ * The local run deliberately does not terminate TLS, and therefore does not serve
+ * HTTP/2. Build the binary when those are what you need to exercise.
+ */
+@RestController
+public class Api {
+
+ /** What a load balancer polls. A String answer is sent as text. */
+ @GetMapping("/healthz")
+ public String health() {
+ return "ok";
+ }
+
+ /** Anything that is not a String is sent as JSON. */
+ @GetMapping("/echo")
+ public Map echo(@RequestParam(value = "say", defaultValue = "hello") String say) {
+ Map out = new LinkedHashMap();
+ out.put("say", say);
+ return out;
+ }
+}
diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml
index c659deb855e..14d8b3569de 100644
--- a/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml
+++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml
@@ -218,6 +218,25 @@
linux
+
+
+ backend
+
+
+ codename1.platform
+ backend
+
+
+
+ backend
+
+ android
diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml
index 6a2f6b03bde..ce7b1a5f659 100644
--- a/maven/codenameone-maven-plugin/pom.xml
+++ b/maven/codenameone-maven-plugin/pom.xml
@@ -55,6 +55,18 @@
jdom22.0.6.1
+
+
+ com.codenameone
+ codenameone-backend
+ ${project.version}
+ test
+ junitjunit
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java
new file mode 100644
index 00000000000..843374428f1
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java
@@ -0,0 +1,834 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.maven;
+
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Component;
+import org.apache.maven.plugins.annotations.Execute;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+import org.apache.maven.repository.RepositorySystem;
+import org.apache.maven.artifact.resolver.ArtifactResolutionRequest;
+import org.apache.maven.artifact.resolver.ArtifactResolutionResult;
+import org.apache.maven.artifact.repository.ArtifactRepository;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import com.codename1.maven.annotations.AnnotatedClass;
+import com.codename1.maven.annotations.ClassScanner;
+import com.codename1.maven.annotations.ProcessingException;
+import com.codename1.maven.annotations.ProcessorContext;
+import com.codename1.maven.processors.RestControllerAnnotationProcessor;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * Translates a backend module to C and compiles it to a native binary:
+ * `mvn cn1:backend-package`.
+ *
+ * The counterpart to {@link BackendRunMojo}. That one runs the module on this JVM
+ * in a couple of seconds and is what a developer uses; this one produces the
+ * artifact that deploys -- a single executable with no runtime to install, which
+ * is what makes a scratch container the size of the binary and a Lambda cold start
+ * a process exec.
+ *
+ * What it does, in order:
+ *
+ * 1. Compiles the module's sources together with the backend runtime's SHARED and
+ * PARPARVM halves against the ParparVM JavaAPI as the BOOTCLASSPATH. That last
+ * part is the important one: the bootclasspath IS the server-safe surface, so a
+ * reference to something the translated runtime does not have fails here, in
+ * the IDE and in the build, rather than at link time or in production.
+ * 2. Runs the translator over the result, with the runtime's C sources already in
+ * the source root -- they have to be there BEFORE it runs, because a native's
+ * Java method is kept alive by its C symbol being present.
+ * 3. Compiles the generated C, either with the host compiler or, for a named
+ * Linux target, in a container.
+ *
+ * The compiler flags are not negotiable and are documented at the call site:
+ * generated C relies on wrapping arithmetic, and clang -O3 miscompiles it without
+ * them.
+ */
+// Forks the lifecycle up to compile first, so `mvn cn1:backend-package` on its own does
+// the obvious thing on a clean checkout instead of failing on an empty
+// target/classes.
+@Execute(phase = LifecyclePhase.COMPILE)
+@Mojo(name = "backend-package", requiresDependencyResolution = ResolutionScope.COMPILE)
+public class BackendPackageMojo extends AbstractMojo {
+
+ @Parameter(defaultValue = "${project}", readonly = true, required = true)
+ private MavenProject project;
+
+ @Parameter(defaultValue = "${localRepository}", readonly = true, required = true)
+ private ArtifactRepository localRepository;
+
+ @Component
+ private RepositorySystem repositorySystem;
+
+ /**
+ * The class whose main() becomes the program's entry point.
+ *
+ * Optional. A module whose server is written as `@RestController` classes has no
+ * main of its own -- one is generated from them -- and naming a class that does
+ * not exist is worse than leaving this out.
+ */
+ @Parameter(property = "cn1.backend.mainClass")
+ private String mainClass;
+
+ /**
+ * Where the binary goes. Defaults to target/<artifactId>.
+ */
+ @Parameter(property = "cn1.backend.output")
+ private File output;
+
+ /**
+ * A Linux deployment target -- musl-x86_64, musl-arm64, glibc-x86_64,
+ * glibc-arm64 -- built in a container. Omitted, it compiles for this machine
+ * with the host compiler, which is what a developer wants and what CI checks.
+ */
+ @Parameter(property = "cn1.backend.target")
+ private String target;
+
+ /** A JDK 8, which is what the translator's front end requires. */
+ @Parameter(property = "cn1.backend.jdk8", defaultValue = "${env.JDK_8_HOME}")
+ private String jdk8Home;
+
+ /** Extra flags for the C compiler. */
+ @Parameter(property = "cn1.backend.cflags")
+ private String cflags;
+
+ /**
+ * Whether to link the bundled SQLite engine. Off saves about 2MB in a service
+ * that talks to PostgreSQL or MySQL instead, which speak their wire protocols
+ * with no engine linked at all.
+ */
+ @Parameter(property = "cn1.backend.sqlite", defaultValue = "true")
+ private boolean sqlite;
+
+ /**
+ * Whether a failed cast throws.
+ *
+ * On by default here and off for app targets, which is the one place the
+ * server build departs from the mobile one deliberately: on a phone a bad cast
+ * costs one user a crash, and on a server the object with the wrong type
+ * arrived from the network, so reading its fields as another type kills every
+ * connection the process was serving.
+ */
+ @Parameter(property = "cn1.backend.checkedCasts", defaultValue = "true")
+ private boolean checkedCasts;
+
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ File jdk8 = resolveJdk8();
+ File work = new File(project.getBuild().getDirectory(), "cn1-backend");
+ File classes = new File(work, "classes");
+ File javaApi = new File(work, "javaapi-classes");
+ File runtimeSources = new File(work, "runtime-src");
+ File nativeSources = new File(work, "native");
+ File translated = new File(work, "translated");
+ // Emptied, not just created. Every one of these is derived, and nothing here
+ // removes a file that stopped being produced: a renamed or deleted source
+ // left its old .class behind, the translator still read it, and even
+ // requireMainClass accepted a main class the module no longer had -- so the
+ // package that came out was the previous implementation. Rebuilding from
+ // clean costs nothing, since neither the javac nor the clang pass below was
+ // ever incremental.
+ emptyDirs(classes, javaApi, runtimeSources, nativeSources, translated);
+ mkdirs(work, classes, javaApi, runtimeSources, nativeSources, translated);
+
+ // The version of the runtime THIS MODULE compiles against, not the
+ // module's own: the sources handed to the translator have to be the same
+ // ones behind the classes the developer just built against, or the local
+ // run and the deployed binary are different programs.
+ String runtimeVersion = backendRuntimeVersion();
+ File runtimeJar = resolve("com.codenameone", "codenameone-backend",
+ runtimeVersion, "parparvm-sources");
+ unzip(runtimeJar, runtimeSources, nativeSources);
+ File parparvmBundle = resolve("com.codenameone", "codenameone-parparvm",
+ runtimeVersion, "bundle");
+ File bundleDir = new File(work, "parparvm");
+ mkdirs(bundleDir);
+ unzip(parparvmBundle, bundleDir, null);
+ File compilerJar = new File(bundleDir, "parparvm-compiler.jar");
+ File javaApiJar = new File(bundleDir, "parparvm-java-api.jar");
+ if (!compilerJar.isFile() || !javaApiJar.isFile()) {
+ throw new MojoExecutionException("The ParparVM bundle is missing its "
+ + "compiler or JavaAPI jar: " + parparvmBundle);
+ }
+ unzip(javaApiJar, javaApi, null);
+
+ compile(jdk8, javaApi, runtimeSources, classes);
+ generateControllers(classes, work);
+ requireMainClass(classes);
+ translate(jdk8, compilerJar, javaApi, classes, nativeSources, translated);
+ File binary = output != null ? output
+ : new File(project.getBuild().getDirectory(), project.getArtifactId());
+ link(translated, binary);
+ getLog().info("built " + binary);
+ }
+
+ /**
+ * Compiles the module's sources and the runtime's against the JavaAPI as the
+ * BOOTCLASSPATH. See the class comment for why that matters.
+ */
+ /**
+ * Generates the routers and the bootstrap for this module's `@RestController`
+ * classes, into the directory this goal has just compiled into.
+ *
+ * Not left to the `process-annotations` goal, which writes into Maven's
+ * target/classes: that is a different build, made against a JDK rather than
+ * against the backend's class library, and the translator never reads it. A
+ * router generated there would be absent from the binary while looking present
+ * in the project. Generating into the tree that is about to be translated is
+ * what makes the wiring real.
+ *
+ * Sets mainClass to the generated bootstrap when the module did not name one.
+ */
+ private void generateControllers(File classes, File work) throws MojoExecutionException {
+ Map index;
+ try {
+ index = ClassScanner.scan(classes);
+ } catch (ProcessingException err) {
+ throw new MojoExecutionException("Could not scan the compiled backend classes: "
+ + err.getMessage(), err);
+ }
+ RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor();
+ ProcessorContext ctx = new ProcessorContext(classes, new File(work, "stubs"), index,
+ getLog(), project.getBasedir(), new Properties(), mainClass,
+ java.util.Collections.emptyList(), "UTF-8",
+ compileClasspathWithoutRuntime());
+ try {
+ processor.start(ctx);
+ for (AnnotatedClass cls : index.values()) {
+ if (!cls.getClassAnnotations().isEmpty()) {
+ processor.processClass(cls, ctx);
+ }
+ }
+ processor.finish(ctx);
+ } catch (ProcessingException err) {
+ throw new MojoExecutionException("Could not process @RestController: "
+ + err.getMessage(), err);
+ }
+ if (ctx.hasErrors()) {
+ StringBuilder sb = new StringBuilder("@RestController could not be processed:");
+ for (ProcessorContext.ProcessingError e : ctx.getErrors()) {
+ sb.append("\n ").append(e);
+ }
+ throw new MojoExecutionException(sb.toString());
+ }
+ byte[] generated = ctx.getEmittedResources()
+ .get(RestControllerAnnotationProcessor.MAIN_CLASS_RESOURCE);
+ if (generated == null) {
+ return;
+ }
+ String name;
+ try {
+ name = new String(generated, "UTF-8").trim();
+ } catch (java.io.UnsupportedEncodingException err) {
+ throw new MojoExecutionException("UTF-8 is required of every JDK", err);
+ }
+ if (mainClass == null || mainClass.length() == 0) {
+ mainClass = name;
+ getLog().info("cn1: entry point " + name + ", generated from @RestController");
+ }
+ }
+
+ /**
+ * Fails here, with the reason, rather than inside the translator.
+ *
+ * The compile below reads .java and only .java, on purpose: recompiling against
+ * the JavaAPI bootclasspath is what turns "this backend uses a class the runtime
+ * does not have" into a compile error instead of a link failure on the device,
+ * and reusing the jar Maven already built would give that up. The cost is that a
+ * main class written in Kotlin -- which `cn1:backend` runs happily, because that
+ * goal is a JVM launch -- never reaches this directory, and the translator's own
+ * complaint about it names neither Kotlin nor the reason. So say it plainly. The
+ * developer guide's "Limits worth knowing" carries the same statement.
+ */
+ private void requireMainClass(File classes) throws MojoFailureException {
+ if (mainClass == null || mainClass.length() == 0) {
+ throw new MojoFailureException("No entry point: set , or annotate "
+ + "a class with @RestController and let the bootstrap be generated "
+ + "from it");
+ }
+ if (new File(classes, mainClass.replace('.', '/') + ".class").isFile()) {
+ return;
+ }
+ throw new MojoFailureException("The main class " + mainClass + " was not "
+ + "produced by the backend compile. This goal compiles Java sources "
+ + "against the backend class library, so a main class written in "
+ + "Kotlin or generated into the build output is not visible to it "
+ + "yet -- write the entry point in Java, or keep it on the JVM with "
+ + "cn1:backend");
+ }
+
+ private void compile(File jdk8, File javaApi, File runtimeSources, File classes)
+ throws MojoExecutionException, MojoFailureException {
+ List sources = new ArrayList();
+ for (Object root : project.getCompileSourceRoots()) {
+ collectJava(new File(String.valueOf(root)), sources);
+ }
+ collectJava(runtimeSources, sources);
+ if (sources.isEmpty()) {
+ throw new MojoFailureException("No Java sources to compile");
+ }
+
+ List command = new ArrayList(Arrays.asList(
+ new File(jdk8, "bin/javac").getAbsolutePath(),
+ "-nowarn", "-encoding", "UTF-8",
+ "-bootclasspath", javaApi.getAbsolutePath(),
+ "-source", "1.8", "-target", "1.8",
+ "-d", classes.getAbsolutePath()));
+ // The module's own dependencies, MINUS the backend runtime: its compiled
+ // form was built against a JDK, and the sources unpacked above are the
+ // half that belongs on this bootclasspath.
+ List classpath = new ArrayList();
+ for (Object element : compileClasspathWithoutRuntime()) {
+ classpath.add(String.valueOf(element));
+ }
+ if (!classpath.isEmpty()) {
+ command.add("-classpath");
+ command.add(join(classpath, File.pathSeparator));
+ }
+ command.addAll(sources);
+ run(command, project.getBasedir(), "compile the backend sources");
+ stageResources(classes);
+ }
+
+ /**
+ * Copies the module's resources in beside the classes just compiled.
+ *
+ * This directory is emptied and then filled from .java alone, and the project's
+ * own output directory is excluded from the translator input on purpose -- its
+ * classes were built against a JDK. The consequence was that anything read from
+ * the classpath, a properties or configuration file, was present under
+ * cn1:backend and simply absent from the packaged binary. Nothing failed at
+ * build time; the resource was just not there at runtime.
+ *
+ * Everything EXCEPT .class is taken, which is exactly the resources and none of
+ * the JDK-compiled code.
+ *
+ * ONLY Maven's processed output, never the raw resource directories. Those
+ * directories are what a / selects FROM, so copying them
+ * wholesale packaged the files the build was configured to leave out -- an
+ * environment file or a secret excluded on purpose would have gone into the
+ * executable, and the later overlay could not remove it. The processed copy is
+ * the answer Maven already computed.
+ */
+ private void stageResources(File classes) throws MojoExecutionException {
+ File processed = new File(project.getBuild().getOutputDirectory());
+ if (!processed.isDirectory()) {
+ // Nothing has processed the resources, so there are none to stage and
+ // nothing to guess at. Said out loud, because a resource silently absent
+ // from the binary is the failure this whole step exists to prevent.
+ if (!project.getBuild().getResources().isEmpty()) {
+ getLog().warn("cn1: this module declares resources but "
+ + processed + " does not exist, so none are packaged. Run "
+ + "process-resources first, or invoke this through the "
+ + "lifecycle rather than as a bare goal.");
+ }
+ return;
+ }
+ try {
+ int staged = copyNonClasses(processed, classes);
+ // Staged is not the same as READABLE, and the difference is silent.
+ // These files reach the translator, so anything that reads them at
+ // BUILD time works -- but the backend translates as app type "clean",
+ // and only the linux and windows types embed classpath resources into
+ // the binary. The clean runtime's Class.getResourceAsStream returns
+ // null unconditionally, so getResourceAsStream finds the file under
+ // cn1:backend, on the JVM, and finds nothing in the packaged
+ // executable. Said out loud rather than left to be discovered in
+ // production; embedding them is a change to the translator and the
+ // shared runtime, not to this goal.
+ if (staged > 0) {
+ getLog().warn("cn1: staged " + staged + " resource file(s) for translation, "
+ + "but a packaged backend cannot READ them: getResourceAsStream "
+ + "answers null in the translated runtime, though it works under "
+ + "cn1:backend. Read configuration from a file path or the "
+ + "environment instead of the classpath.");
+ }
+ } catch (IOException err) {
+ // A resource that cannot be staged is a packaging failure, not a note:
+ // the executable would be reported as built while missing something
+ // cn1:backend has, and the difference would first appear in production.
+ throw new MojoExecutionException("Could not stage the processed resources "
+ + "from " + processed + " into " + classes, err);
+ }
+ }
+
+ /** @return how many non-class files were copied. */
+ private int copyNonClasses(File from, File to) throws IOException {
+ if (from == null || !from.isDirectory()) {
+ return 0;
+ }
+ File[] children = from.listFiles();
+ if (children == null) {
+ return 0;
+ }
+ int copied = 0;
+ for (File child : children) {
+ File target = new File(to, child.getName());
+ if (child.isDirectory()) {
+ target.mkdirs();
+ copied += copyNonClasses(child, target);
+ } else if (!child.getName().endsWith(".class")) {
+ copyFile(child, target);
+ copied++;
+ }
+ }
+ return copied;
+ }
+
+ private static void copyFile(File from, File to) throws IOException {
+ InputStream in = new java.io.FileInputStream(from);
+ try {
+ OutputStream out = new java.io.FileOutputStream(to);
+ try {
+ byte[] chunk = new byte[8192];
+ int n;
+ while ((n = in.read(chunk)) > 0) {
+ out.write(chunk, 0, n);
+ }
+ } finally {
+ out.close();
+ }
+ } finally {
+ in.close();
+ }
+ }
+
+ private List compileClasspathWithoutRuntime() throws MojoExecutionException {
+ List out = new ArrayList();
+ try {
+ for (Object element : project.getCompileClasspathElements()) {
+ String path = String.valueOf(element);
+ if (path.indexOf("codenameone-backend") >= 0) {
+ continue;
+ }
+ if (path.equals(project.getBuild().getOutputDirectory())) {
+ continue;
+ }
+ out.add(path);
+ }
+ } catch (Exception err) {
+ throw new MojoExecutionException("Could not resolve the compile classpath", err);
+ }
+ return out;
+ }
+
+ private void translate(File jdk8, File compilerJar, File javaApi, File classes,
+ File nativeSources, File translated)
+ throws MojoExecutionException, MojoFailureException {
+ String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1);
+ String packageName = mainClass.lastIndexOf('.') < 0 ? ""
+ : mainClass.substring(0, mainClass.lastIndexOf('.'));
+
+ // The C has to be in the source root BEFORE the translator runs: it reads
+ // the directory to decide which native-only Java methods to keep, and the
+ // signature verifier checks every declared native against an actual
+ // implementation.
+ File sourceDir = new File(translated, "dist/" + simpleName + "-src");
+ mkdirs(sourceDir);
+ copyDirectory(nativeSources, sourceDir);
+
+ List command = new ArrayList();
+ command.add(new File(jdk8, "bin/java").getAbsolutePath());
+ if (sqlite) {
+ command.add("-Dcn1.sqlite=true");
+ }
+ if (checkedCasts) {
+ command.add("-Dcn1.checkedCasts=true");
+ }
+ command.add("-cp");
+ command.add(compilerJar.getAbsolutePath());
+ command.add("com.codename1.tools.translator.ByteCodeTranslator");
+ command.add("clean");
+ // The module's dependencies belong on the translator's input, not only on
+ // javac's classpath. Without them a backend that uses a type from another
+ // module -- the shared contract or DTO module the generated project
+ // recommends -- compiles here and then fails to translate, because javac
+ // resolved the type from a jar whose bytecode the translator never sees.
+ // The runtime is excluded for the same reason it is excluded from javac's
+ // classpath: its sources are compiled into `classes` already.
+ StringBuilder translatorInput = new StringBuilder();
+ translatorInput.append(javaApi.getAbsolutePath())
+ .append(';').append(classes.getAbsolutePath());
+ for (String element : compileClasspathWithoutRuntime()) {
+ translatorInput.append(';').append(element);
+ }
+ command.add(translatorInput.toString());
+ command.add(translated.getAbsolutePath());
+ command.add(simpleName);
+ command.add(packageName);
+ command.add(simpleName);
+ command.add("1.0");
+ command.add("clean");
+ command.add("none");
+ run(command, project.getBasedir(), "translate the backend to C");
+ }
+
+ private void link(File translated, File binary)
+ throws MojoExecutionException, MojoFailureException {
+ String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1);
+ File sourceDir = new File(translated, "dist/" + simpleName + "-src");
+ // Kept as a loud failure rather than dropped: the parameter names a real
+ // capability, and silently ignoring -Dcn1.backend.target would hand back a
+ // host binary labelled as a cross-compiled one. The script named here lives in
+ // the Codename One repository, not in a generated project, which is why the
+ // message says where it is instead of assuming it is on hand.
+ if (target != null && target.length() > 0) {
+ throw new MojoFailureException("cn1.backend.target is not supported from "
+ + "this goal yet: it builds for the machine it runs on. The "
+ + "cross-compiled targets (musl-x86_64, musl-arm64, glibc-x86_64, "
+ + "glibc-arm64) are produced by package.sh in the Codename One "
+ + "repository, which drives one container image per target; run "
+ + "this goal inside a container of the target flavour to get the "
+ + "same artifact here");
+ }
+ List command = new ArrayList(Arrays.asList(
+ "clang", "-O3", "-w",
+ // Mandatory for generated C: Java arithmetic wraps, and clang -O3
+ // provably miscompiles the output without these.
+ "-fwrapv", "-fno-strict-aliasing",
+ "-fno-builtin-fmod", "-fno-builtin-fmodf"));
+ if (!sqlite) {
+ // Turning the engine OFF is two changes, not one. Without
+ // -Dcn1.sqlite=true the translator leaves cn1_sqlite3.h out, but
+ // cn1_backend_db.c is copied and compiled either way -- and its
+ // SQLite branch includes that header unconditionally, so the compile
+ // fails with "cn1_sqlite3.h file not found" and the option advertised
+ // as saving the engine could not produce a binary at all. The macro
+ // is what compiles that file to stubs instead, which answer "could
+ // not open" and become an IOException, rather than dropping the Db
+ // natives and taking their Java methods with them. build.sh has
+ // always set both; this half had only the first.
+ command.add("-DCN1_BACKEND_NO_SQLITE");
+ }
+ if (cflags != null && cflags.trim().length() > 0) {
+ command.addAll(Arrays.asList(cflags.trim().split("\\s+")));
+ }
+ command.add("-I" + sourceDir.getAbsolutePath());
+ File[] cFiles = sourceDir.listFiles();
+ if (cFiles == null) {
+ throw new MojoExecutionException("The translator produced nothing in " + sourceDir);
+ }
+ for (File file : cFiles) {
+ String name = file.getName();
+ // .S as well as .c, which is what vm/backend/build.sh compiles. The
+ // translator always emits cn1_virtual_thread_asm.S, and
+ // cn1_virtual_thread.c calls cn1VirtualThreadSwitch out of it, so a
+ // command that passed only .c reached the linker with that symbol
+ // undefined and this goal could not produce a binary at all.
+ // Generated resource assembly is in the same position.
+ if (name.endsWith(".c") || name.endsWith(".S") || name.endsWith(".s")) {
+ command.add(file.getAbsolutePath());
+ }
+ }
+ command.addAll(Arrays.asList("-lm", "-lpthread",
+ "-lcurl", "-lssl", "-lcrypto", "-lnghttp2"));
+ command.add("-o");
+ command.add(binary.getAbsolutePath());
+ run(command, project.getBasedir(), "compile the generated C");
+ }
+
+ /**
+ * The codenameone-backend version this module depends on.
+ *
+ * Deliberately an error rather than a default when the dependency is absent:
+ * guessing a version here would translate a different runtime from the one the
+ * module was compiled and tested against.
+ */
+ private String backendRuntimeVersion() throws MojoFailureException {
+ java.util.Set artifacts = project.getArtifacts();
+ if (artifacts != null) {
+ for (Artifact artifact : artifacts) {
+ if ("com.codenameone".equals(artifact.getGroupId())
+ && "codenameone-backend".equals(artifact.getArtifactId())) {
+ return artifact.getVersion();
+ }
+ }
+ }
+ throw new MojoFailureException("This module does not depend on "
+ + "com.codenameone:codenameone-backend, so there is no backend "
+ + "runtime to translate. Add it as a dependency.");
+ }
+
+ private File resolveJdk8() throws MojoFailureException {
+ if (jdk8Home != null && jdk8Home.length() > 0) {
+ File home = new File(jdk8Home);
+ if (new File(home, "bin/javac").isFile()) {
+ return home;
+ }
+ }
+ throw new MojoFailureException("A JDK 8 is required to translate; set "
+ + "JDK_8_HOME or -Dcn1.backend.jdk8");
+ }
+
+ private File resolve(String groupId, String artifactId, String version, String classifier)
+ throws MojoExecutionException {
+ Artifact artifact = repositorySystem.createArtifactWithClassifier(
+ groupId, artifactId, version, "jar", classifier);
+ ArtifactResolutionRequest request = new ArtifactResolutionRequest();
+ request.setArtifact(artifact);
+ request.setLocalRepository(localRepository);
+ request.setRemoteRepositories(project.getRemoteArtifactRepositories());
+ ArtifactResolutionResult result = repositorySystem.resolve(request);
+ if (!result.isSuccess() || artifact.getFile() == null) {
+ throw new MojoExecutionException("Could not resolve " + groupId + ":"
+ + artifactId + ":" + version + ":" + classifier);
+ }
+ return artifact.getFile();
+ }
+
+ /**
+ * Unpacks a jar. Entries under cn1-native/ go to `nativeTarget` when one is
+ * given, because the C belongs in the translator's source root rather than on
+ * the Java source path.
+ */
+ private void unzip(File jar, File javaTarget, File nativeTarget)
+ throws MojoExecutionException {
+ try {
+ ZipFile zip = new ZipFile(jar);
+ try {
+ Enumeration extends ZipEntry> entries = zip.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry entry = entries.nextElement();
+ if (entry.isDirectory()) {
+ continue;
+ }
+ String name = entry.getName();
+ File destination;
+ if (name.startsWith("cn1-native/")) {
+ if (nativeTarget == null) {
+ continue;
+ }
+ destination = resolveInside(nativeTarget,
+ name.substring("cn1-native/".length()), jar, name);
+ } else if (name.startsWith("META-INF/")) {
+ continue;
+ } else {
+ destination = resolveInside(javaTarget, name, jar, name);
+ }
+ mkdirs(destination.getParentFile());
+ InputStream in = zip.getInputStream(entry);
+ try {
+ copy(in, destination);
+ } finally {
+ in.close();
+ }
+ }
+ } finally {
+ zip.close();
+ }
+ } catch (IOException err) {
+ throw new MojoExecutionException("Could not unpack " + jar, err);
+ }
+ }
+
+ /**
+ * The entry's destination, proven to be inside the directory it unpacks into.
+ *
+ * An archive entry name is attacker-controlled data, not a path this build
+ * chose: an entry called `../../../../etc/whatever` makes `new File(root, name)`
+ * resolve outside `root`, so unpacking writes wherever the entry says. That is
+ * Zip Slip, and here it would run with the developer's privileges during an
+ * ordinary `mvn package` against whatever jar the coordinates resolved to.
+ *
+ * Compared after canonicalisation rather than on the raw string, because `..`
+ * is not the only way out -- a symlinked parent resolves elsewhere too, and the
+ * textual check passes for both. The separator is appended to the root so a
+ * sibling whose name merely starts with it ("/tmp/outdir-evil" against
+ * "/tmp/outdir") cannot satisfy the prefix test.
+ */
+ private static File resolveInside(File root, String relative, File jar, String entryName)
+ throws IOException {
+ File destination = new File(root, relative);
+ String prefix = root.getCanonicalPath() + File.separator;
+ String resolved = destination.getCanonicalPath();
+ if (!resolved.startsWith(prefix)) {
+ throw new IOException("Refusing to unpack " + jar + ": entry \"" + entryName
+ + "\" resolves to " + resolved + ", outside " + root.getCanonicalPath());
+ }
+ return destination;
+ }
+
+ private static void copy(InputStream in, File destination) throws IOException {
+ OutputStream out = new FileOutputStream(destination);
+ try {
+ byte[] chunk = new byte[8192];
+ int n;
+ while ((n = in.read(chunk)) > 0) {
+ out.write(chunk, 0, n);
+ }
+ } finally {
+ out.close();
+ }
+ }
+
+ private void copyDirectory(File from, File to) throws MojoExecutionException {
+ File[] children = from.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ File destination = new File(to, child.getName());
+ if (child.isDirectory()) {
+ mkdirs(destination);
+ copyDirectory(child, destination);
+ continue;
+ }
+ try {
+ InputStream in = new java.io.FileInputStream(child);
+ try {
+ copy(in, destination);
+ } finally {
+ in.close();
+ }
+ } catch (IOException err) {
+ throw new MojoExecutionException("Could not copy " + child, err);
+ }
+ }
+ }
+
+ private void collectJava(File dir, List out) {
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ collectJava(child, out);
+ } else if (child.getName().endsWith(".java")) {
+ out.add(child.getAbsolutePath());
+ }
+ }
+ }
+
+ private void run(List command, File directory, String what)
+ throws MojoExecutionException, MojoFailureException {
+ try {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.directory(directory);
+ builder.redirectErrorStream(true);
+ Process process = builder.start();
+ StringBuilder output = new StringBuilder();
+ InputStream in = process.getInputStream();
+ byte[] chunk = new byte[8192];
+ int n;
+ while ((n = in.read(chunk)) > 0) {
+ output.append(new String(chunk, 0, n, "UTF-8"));
+ }
+ int status = process.waitFor();
+ if (status != 0) {
+ throw new MojoFailureException("Could not " + what + ":\n" + output);
+ }
+ if (output.length() > 0) {
+ getLog().debug(output.toString());
+ }
+ } catch (IOException err) {
+ throw new MojoExecutionException("Could not " + what, err);
+ } catch (InterruptedException err) {
+ Thread.currentThread().interrupt();
+ throw new MojoExecutionException("Interrupted while trying to " + what, err);
+ }
+ }
+
+ /**
+ * Removes each directory and its contents, and refuses to continue if one
+ * survives.
+ *
+ * The emptiness is the point, and it used to be assumed: File.delete returns
+ * false for a locked file on Windows or anything under a read-only directory,
+ * nothing looked at that, and the stale .class stayed where ClassScanner,
+ * requireMainClass and the translator would all find it. A controller or an
+ * entry point deleted from the source tree is then still packaged, so the
+ * build ships the previous implementation and says nothing. Checking the
+ * result rather than each delete catches every reason one can survive.
+ */
+ private static void emptyDirs(File... dirs) throws MojoExecutionException {
+ for (File dir : dirs) {
+ deleteTree(dir);
+ if (dir == null || !dir.exists()) {
+ continue;
+ }
+ String[] left = dir.list();
+ if (left != null && left.length > 0) {
+ throw new MojoExecutionException("Could not empty " + dir
+ + ": " + left.length + " entr" + (left.length == 1 ? "y" : "ies")
+ + " could not be deleted, and building over them would package "
+ + "classes that are no longer in the source tree.");
+ }
+ }
+ }
+
+ private static void deleteTree(File file) {
+ if (file == null || !file.exists()) {
+ return;
+ }
+ File[] children = file.listFiles();
+ if (children != null) {
+ for (File child : children) {
+ deleteTree(child);
+ }
+ }
+ // The result is checked by emptyDirs, which looks at what actually
+ // survived rather than at each delete: a directory that could not be
+ // removed but is empty is harmless, and one that still holds a class is
+ // not, whatever the reason.
+ file.delete();
+ }
+
+ private static void mkdirs(File... dirs) {
+ for (File dir : dirs) {
+ if (dir != null && !dir.isDirectory()) {
+ dir.mkdirs();
+ }
+ }
+ }
+
+ private static String join(List parts, String separator) {
+ StringBuilder out = new StringBuilder();
+ for (int iter = 0; iter < parts.size(); iter++) {
+ if (iter > 0) {
+ out.append(separator);
+ }
+ out.append(parts.get(iter));
+ }
+ return out.toString();
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java
new file mode 100644
index 00000000000..3da203233ce
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendRunMojo.java
@@ -0,0 +1,292 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.maven;
+
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Execute;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Runs a backend module on this JVM: `mvn cn1:backend`.
+ *
+ * The point is speed. The same handler translated to a native binary takes about
+ * a minute and a half to build; here it starts in a couple of seconds, because
+ * the shared runtime (`codenameone-backend`) is ordinary Java compiled against
+ * the JDK and only the classes underneath it differ. Nothing in the protocol
+ * layer is a stand-in -- it is the same source that ships -- so what runs here
+ * behaves the way the deployed binary does.
+ *
+ * What this local runtime deliberately does NOT do is terminate TLS, and
+ * therefore serve HTTP/2: Tls and Http2 refuse with a message saying so. A second
+ * SSLEngine-based handshake would have its own bugs rather than production's,
+ * which is worse than not having it because it looks like coverage. Run
+ * `cn1:backend-package` when TLS is what you need to exercise.
+ */
+// Forks the lifecycle up to process-classes first, so `mvn cn1:backend` on its own
+// does the obvious thing on a clean checkout instead of failing on an empty
+// target/classes.
+//
+// process-classes rather than compile because that is where process-annotations is
+// bound: a server written as @RestController classes has its router and its main
+// GENERATED there, so stopping at compile would leave this goal looking for an entry
+// point that the build had not produced yet.
+@Execute(phase = LifecyclePhase.PROCESS_CLASSES)
+@Mojo(name = "backend", requiresDependencyResolution = ResolutionScope.RUNTIME)
+public class BackendRunMojo extends AbstractMojo {
+
+ @Parameter(defaultValue = "${project}", readonly = true, required = true)
+ private MavenProject project;
+
+ /**
+ * The class to run. Found automatically when the module has exactly one class
+ * with a main method, which is the usual shape.
+ */
+ @Parameter(property = "cn1.backend.mainClass")
+ private String mainClass;
+
+ /** Arguments for the program, space separated. */
+ @Parameter(property = "cn1.backend.args")
+ private String args;
+
+ /** Extra JVM options, space separated. */
+ @Parameter(property = "cn1.backend.jvmArgs")
+ private String jvmArgs;
+
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ File classes = new File(project.getBuild().getOutputDirectory());
+ if (!classes.isDirectory()) {
+ throw new MojoFailureException("Nothing is compiled in "
+ + classes + "; run `mvn compile` first, or `mvn compile cn1:backend`");
+ }
+
+ List classpath = new ArrayList();
+ classpath.add(classes.getAbsolutePath());
+ try {
+ for (Object element : project.getRuntimeClasspathElements()) {
+ String path = String.valueOf(element);
+ if (!classpath.contains(path)) {
+ classpath.add(path);
+ }
+ }
+ } catch (Exception err) {
+ throw new MojoExecutionException("Could not resolve the runtime classpath", err);
+ }
+
+ String main = mainClass;
+ if (main == null || main.length() == 0) {
+ // The generator's own answer first. Annotation processing writes the
+ // entry point it created into META-INF/cn1-backend-main, and that is
+ // a statement of WHICH main to run -- scanning for main methods is a
+ // guess, and it fails the moment the module also holds a demo or a
+ // tool with one: the run is refused as ambiguous though the choice
+ // had already been made. cn1:backend-package reads the same marker,
+ // and the two must not disagree about what the module runs.
+ main = generatedMainClass(classes);
+ }
+ if (main == null || main.length() == 0) {
+ main = findMainClass(classes);
+ }
+
+ List command = new ArrayList();
+ command.add(javaExecutable());
+ if (jvmArgs != null && jvmArgs.trim().length() > 0) {
+ command.addAll(Arrays.asList(jvmArgs.trim().split("\\s+")));
+ }
+ command.add("-cp");
+ command.add(join(classpath, File.pathSeparator));
+ command.add(main);
+ if (args != null && args.trim().length() > 0) {
+ command.addAll(Arrays.asList(args.trim().split("\\s+")));
+ }
+
+ getLog().info("Running " + main + " on " + System.getProperty("java.version"));
+ try {
+ ProcessBuilder run = new ProcessBuilder(command);
+ run.directory(project.getBasedir());
+ // Inherited rather than captured: a server logs as it serves, and a
+ // developer watching `cn1:backend` wants those lines as they happen.
+ // It also means Ctrl-C reaches the server, so its shutdown handler
+ // runs and in-flight requests finish.
+ run.inheritIO();
+ Process process = run.start();
+ int status = process.waitFor();
+ if (status != 0) {
+ throw new MojoFailureException(main + " exited with status " + status);
+ }
+ } catch (IOException err) {
+ throw new MojoExecutionException("Could not start " + main, err);
+ } catch (InterruptedException err) {
+ Thread.currentThread().interrupt();
+ throw new MojoExecutionException("Interrupted while running " + main, err);
+ }
+ }
+
+ /**
+ * The one class in this module with a main method.
+ *
+ * Deliberately an error when there are several rather than a guess: picking
+ * one and running it is how a developer ends up debugging the wrong process.
+ */
+ /**
+ * The entry point annotation processing generated, or null when this module
+ * has none -- one written by hand, with no @RestController in it, has no
+ * marker and falls through to the scan below.
+ */
+ private String generatedMainClass(File classesDir) {
+ File marker = new File(classesDir,
+ com.codename1.maven.processors.RestControllerAnnotationProcessor
+ .MAIN_CLASS_RESOURCE.replace('/', File.separatorChar));
+ if (!marker.isFile()) {
+ return null;
+ }
+ try {
+ byte[] raw = new byte[(int) marker.length()];
+ InputStream in = new java.io.FileInputStream(marker);
+ try {
+ int at = 0;
+ while (at < raw.length) {
+ int n = in.read(raw, at, raw.length - at);
+ if (n <= 0) {
+ break;
+ }
+ at += n;
+ }
+ } finally {
+ in.close();
+ }
+ String name = new String(raw, "UTF-8").trim();
+ return name.length() == 0 ? null : name;
+ } catch (IOException err) {
+ // Unreadable is not the same as absent, and the scan below still has
+ // a fair chance of being right; refusing outright would be worse.
+ getLog().warn("cn1: could not read " + marker + ": " + err);
+ return null;
+ }
+ }
+
+ private String findMainClass(File classesDir) throws MojoFailureException {
+ List found = new ArrayList();
+ collectMainClasses(classesDir, classesDir, found);
+ if (found.size() == 1) {
+ return found.get(0);
+ }
+ if (found.isEmpty()) {
+ throw new MojoFailureException("No class with a main method under "
+ + classesDir + "; set -Dcn1.backend.mainClass");
+ }
+ throw new MojoFailureException("Several classes have a main method ("
+ + join(found, ", ") + "); choose one with -Dcn1.backend.mainClass");
+ }
+
+ private void collectMainClasses(File root, File dir, List found) {
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ collectMainClasses(root, child, found);
+ } else if (child.getName().endsWith(".class") && child.getName().indexOf('$') < 0) {
+ String name = child.getAbsolutePath()
+ .substring(root.getAbsolutePath().length() + 1)
+ .replace(File.separatorChar, '.');
+ name = name.substring(0, name.length() - ".class".length());
+ if (hasMainMethod(child)) {
+ found.add(name);
+ }
+ }
+ }
+ }
+
+ /**
+ * Whether the class DECLARES `public static void main(String[])`.
+ *
+ * Read from the class file rather than by loading it: loading runs the static
+ * initialiser, and a backend's initialiser is as likely as not to open a
+ * socket or a database. The method table is read with ASM rather than by
+ * searching the bytes, because the constant pool of a class that merely CALLS
+ * main carries the same two strings.
+ */
+ private boolean hasMainMethod(File classFile) {
+ final boolean[] found = new boolean[1];
+ try {
+ InputStream in = new java.io.FileInputStream(classFile);
+ try {
+ new org.objectweb.asm.ClassReader(in).accept(
+ new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) {
+ @Override
+ public org.objectweb.asm.MethodVisitor visitMethod(int access,
+ String name, String descriptor, String signature,
+ String[] exceptions) {
+ int wanted = org.objectweb.asm.Opcodes.ACC_PUBLIC
+ | org.objectweb.asm.Opcodes.ACC_STATIC;
+ if ("main".equals(name)
+ && "([Ljava/lang/String;)V".equals(descriptor)
+ && (access & wanted) == wanted) {
+ found[0] = true;
+ }
+ return null;
+ }
+ },
+ org.objectweb.asm.ClassReader.SKIP_CODE
+ | org.objectweb.asm.ClassReader.SKIP_DEBUG
+ | org.objectweb.asm.ClassReader.SKIP_FRAMES);
+ } finally {
+ in.close();
+ }
+ } catch (Exception err) {
+ return false;
+ }
+ return found[0];
+ }
+
+ private static String javaExecutable() {
+ File home = new File(System.getProperty("java.home"));
+ File candidate = new File(home, "bin/java");
+ return candidate.isFile() ? candidate.getAbsolutePath() : "java";
+ }
+
+ private static String join(List parts, String separator) {
+ StringBuilder out = new StringBuilder();
+ for (int iter = 0; iter < parts.size(); iter++) {
+ if (iter > 0) {
+ out.append(separator);
+ }
+ out.append(parts.get(iter));
+ }
+ return out.toString();
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java
index 44ecf1a114a..af089f6b0a4 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java
@@ -611,7 +611,9 @@ static String extractResponsePayload(String paramSignature) {
return jvmSignatureToJavaType(payload);
}
- private static String jvmSignatureToJavaType(String sig) {
+ /* package-private, not private: RestServerAnnotationProcessor generates the
+ server half of the same contract and needs the identical parsing. */
+ static String jvmSignatureToJavaType(String sig) {
if (sig == null || sig.length() == 0) return "java.lang.Object";
char c = sig.charAt(0);
switch (c) {
@@ -694,20 +696,26 @@ private static List splitTopLevelArgs(String args) {
/// Strips top-level generic parameters from a Java type name so it can be
/// used as a `Class` literal. `List` -> `List`.
- private static String stripGeneric(String javaType) {
+ /* package-private, not private: RestServerAnnotationProcessor generates the
+ server half of the same contract and needs the identical parsing. */
+ static String stripGeneric(String javaType) {
if (javaType == null) return "java.lang.Object";
int lt = javaType.indexOf('<');
return lt < 0 ? javaType : javaType.substring(0, lt);
}
- private static boolean isCallbackType(String descriptor) {
+ /* package-private, not private: RestServerAnnotationProcessor generates the
+ server half of the same contract and needs the identical parsing. */
+ static boolean isCallbackType(String descriptor) {
return "Lcom/codename1/util/OnComplete;".equals(descriptor);
}
/// Returns the Java type name for a parameter, preferring the generic
/// signature when available so `List` survives instead of erasing to
/// `List`.
- private static String javaTypeFor(Type asmType, String genericSig) {
+ /* package-private, not private: RestServerAnnotationProcessor generates the
+ server half of the same contract and needs the identical parsing. */
+ static String javaTypeFor(Type asmType, String genericSig) {
if (genericSig != null && genericSig.length() > 0) {
return jvmSignatureToJavaType(genericSig);
}
@@ -732,17 +740,23 @@ private static String boxIfPrimitive(String type) {
// Misc
// ----------------------------------------------------------------
- private static String packageOf(String binary) {
+ /* package-private, not private: RestServerAnnotationProcessor generates the
+ server half of the same contract and needs the identical parsing. */
+ static String packageOf(String binary) {
int dot = binary.lastIndexOf('.');
return dot < 0 ? "" : binary.substring(0, dot);
}
- private static String simpleName(String binary) {
+ /* package-private, not private: RestServerAnnotationProcessor generates the
+ server half of the same contract and needs the identical parsing. */
+ static String simpleName(String binary) {
int dot = binary.lastIndexOf('.');
return dot < 0 ? binary : binary.substring(dot + 1);
}
- private static String escape(String s) {
+ /* package-private, not private: RestServerAnnotationProcessor generates the
+ server half of the same contract and needs the identical parsing. */
+ static String escape(String s) {
if (s == null) return "";
StringBuilder b = new StringBuilder(s.length() + 4);
for (int i = 0; i < s.length(); i++) {
@@ -753,7 +767,9 @@ private static String escape(String s) {
return b.toString();
}
- private static String sanitizeIdentifier(String s) {
+ /* package-private, not private: RestServerAnnotationProcessor generates the
+ server half of the same contract and needs the identical parsing. */
+ static String sanitizeIdentifier(String s) {
if (s == null || s.length() == 0) return "p";
StringBuilder b = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java
new file mode 100644
index 00000000000..b9f1a9caa2b
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java
@@ -0,0 +1,2111 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.maven.processors;
+
+import com.codename1.maven.annotations.AbstractAnnotationProcessor;
+import com.codename1.maven.annotations.AnnotatedClass;
+import com.codename1.maven.annotations.AnnotationValues;
+import com.codename1.maven.annotations.JavaSourceCompiler;
+import com.codename1.maven.annotations.MethodInfo;
+import com.codename1.maven.annotations.ProcessingException;
+import com.codename1.maven.annotations.ProcessorContext;
+import com.codename1.maven.annotations.ClassScanner;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import org.objectweb.asm.Type;
+
+/**
+ * Generates a router for every `@RestController`, and the `main` that serves them.
+ *
+ * The shape is Spring's on purpose -- `@RestController`, `@GetMapping`,
+ * `@PathVariable`, `@RequestParam`, `@RequestBody`, `@ResponseStatus` mean here what
+ * they mean there -- so that reading one is enough to read the other. What differs is
+ * where the work happens: Spring resolves a route by walking a registry at request
+ * time, and this resolves it at build time into code that compares the request's own
+ * bytes.
+ *
+ * That is the reason to generate rather than reflect. A handwritten
+ * `if ("/healthz".equals(request.getTarget()))` builds a String for the target,
+ * hashes it and compares it, on every request and for every route it tests before the
+ * one that matches; and it is wrong the moment the client appends a query string. The
+ * generated form holds each route as a `byte[]` constant and asks the Request whether
+ * its path bytes are those bytes -- no String, no hash, and the query cannot break it.
+ * A route with no path variables therefore allocates nothing at all, which is what
+ * keeps the collector out of the request path.
+ *
+ * Reflection is not an option regardless: this code is translated to C, and
+ * `Class.forName` on an obfuscated name does not survive that. Generating source that
+ * the same compiler sees is what makes the wiring visible to the dead-code pass.
+ */
+public final class RestControllerAnnotationProcessor extends AbstractAnnotationProcessor {
+
+ private static final String PKG = "Lcom/codename1/backend/annotations/";
+ private static final String CONTROLLER = PKG + "RestController;";
+ private static final String REQUEST_MAPPING = PKG + "RequestMapping;";
+ private static final String PATH_VARIABLE = PKG + "PathVariable;";
+ private static final String REQUEST_PARAM = PKG + "RequestParam;";
+ private static final String REQUEST_HEADER = PKG + "RequestHeader;";
+ private static final String REQUEST_BODY = PKG + "RequestBody;";
+ private static final String RESPONSE_STATUS = PKG + "ResponseStatus;";
+
+ /** Mapping annotation to the HTTP method it stands for. */
+ private static final Map MAPPINGS;
+ static {
+ Map m = new LinkedHashMap();
+ m.put(PKG + "GetMapping;", "GET");
+ m.put(PKG + "PostMapping;", "POST");
+ m.put(PKG + "PutMapping;", "PUT");
+ m.put(PKG + "DeleteMapping;", "DELETE");
+ m.put(PKG + "PatchMapping;", "PATCH");
+ MAPPINGS = Collections.unmodifiableMap(m);
+ }
+
+ private static final String REQUEST_TYPE = "com.codename1.backend.HttpServer.Request";
+ private static final String RESPONSE_TYPE = "com.codename1.backend.HttpServer.Response";
+
+ /**
+ * The same class as the DESCRIPTOR spells it. A nested class is
+ * Outer$Inner in bytecode, and the type derived from the descriptor keeps
+ * that -- so comparing against the dotted source spelling alone never
+ * matched, and both places that ask "is this a Response" were dead code:
+ * emitRoute never took the branch that SENDS one, and the encodable check
+ * never exempted it. Returning a Response from a controller, which the
+ * refusal message itself offers as the way to take control of the reply,
+ * did not work.
+ */
+ private static final String RESPONSE_TYPE_BINARY =
+ "com.codename1.backend.HttpServer$Response";
+
+ /** Either spelling of HttpServer.Response. */
+ private static boolean isResponseType(String javaType) {
+ return RESPONSE_TYPE.equals(javaType) || RESPONSE_TYPE_BINARY.equals(javaType);
+ }
+
+ /**
+ * The verbs HttpServer routes. It compares them with equals and answers 501
+ * to everything else before dispatch, so this list is the whole truth about
+ * what a generated route can be reached by. Kept in the same order the
+ * server declares it.
+ */
+ /**
+ * The JDK types Json.writeValue has a branch for, and therefore the only ones
+ * a handler may return without writing itself. Kept in the order that method
+ * tests them so the two can be read side by side: String; the integral boxes;
+ * the floating ones; Map; List; byte[] (handled as an array before this);
+ * Collection, of which Set is the shape people actually return.
+ */
+ private static final Set JSON_JDK_TYPES = Collections.unmodifiableSet(
+ new LinkedHashSet(Arrays.asList(
+ "java.lang.String", "java.lang.Character",
+ "java.lang.Boolean", "java.lang.Integer", "java.lang.Long",
+ "java.lang.Short", "java.lang.Byte",
+ "java.lang.Double", "java.lang.Float",
+ "java.util.Map", "java.util.HashMap", "java.util.LinkedHashMap",
+ "java.util.TreeMap", "java.util.SortedMap",
+ "java.util.List", "java.util.ArrayList", "java.util.LinkedList",
+ "java.util.Collection", "java.util.Set", "java.util.HashSet",
+ "java.util.LinkedHashSet", "java.util.TreeSet", "java.util.SortedSet")));
+
+ private static final List ROUTABLE_METHODS = Collections.unmodifiableList(
+ Arrays.asList("GET", "POST", "HEAD", "PUT", "DELETE", "PATCH", "OPTIONS"));
+
+ /** Where the generated bootstrap's name is left for the packaging goal to read. */
+ public static final String MAIN_CLASS_RESOURCE = "META-INF/cn1-backend-main";
+
+ private final TreeMap controllers = new TreeMap();
+
+ /**
+ * Every route shape seen so far, across every controller, to the method that
+ * claimed it. Kept beside `controllers` rather than inside one, because the
+ * generated bootstrap chains the routers and returns the first non-null
+ * response: two controllers colliding makes the later one unreachable in
+ * exactly the way two methods in one controller do.
+ */
+ private final Map routeShapes = new LinkedHashMap();
+
+ /** Which controller claimed each shape, so a clash names the other one. */
+ private final Map routeOwners = new LinkedHashMap();
+
+ private static final class Controller {
+ String binaryName;
+ String packageName;
+ String simpleName;
+ String routerSimpleName;
+ List basePaths = new ArrayList();
+ List routes = new ArrayList();
+ }
+
+ private static final class Route {
+ String httpMethod;
+ String pattern;
+ String javaMethod;
+ String returnJavaType;
+ int status;
+ List params = new ArrayList();
+ /** The literal bytes before the first `{`; the whole pattern when there is none. */
+ String prefix;
+ /** For each variable, the literal that must follow it; "" when it runs to the end. */
+ List after = new ArrayList();
+ }
+
+ private static final class Param {
+ String kind; // PATH, QUERY, HEADER, BODY, REQUEST
+ String name;
+ String javaType;
+ /** The same type with its arguments, when the method carried a signature. */
+ String genericJavaType;
+ String defaultValue;
+ /** From the annotation. A request missing a required binding is refused. */
+ boolean required;
+ /** Set when the body is decoded into a local before the call. */
+ String local;
+ int variableIndex = -1;
+ }
+
+ @Override
+ public Set getAnnotationDescriptors() {
+ return Collections.singleton(CONTROLLER);
+ }
+
+ @Override
+ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException {
+ if (cls.getClassAnnotation(CONTROLLER) == null) {
+ return;
+ }
+ if (cls.isInterface() || cls.isAbstract()) {
+ ctx.error(cls, "@RestController must be a concrete class: " + cls.getBinaryName());
+ return;
+ }
+ Controller controller = new Controller();
+ controller.binaryName = cls.getBinaryName();
+ controller.packageName = RestClientAnnotationProcessor.packageOf(controller.binaryName);
+ controller.simpleName = RestClientAnnotationProcessor.simpleName(controller.binaryName);
+ controller.routerSimpleName = controller.simpleName + "Router";
+ controller.basePaths.addAll(pathsOf(cls.getClassAnnotation(REQUEST_MAPPING)));
+ if (controller.basePaths.isEmpty()) {
+ controller.basePaths.add("");
+ }
+ if (!hasNoArgConstructor(cls)) {
+ ctx.error(cls, "@RestController needs a public no-argument constructor so the "
+ + "generated bootstrap can create it: " + controller.binaryName);
+ return;
+ }
+
+ for (MethodInfo m : cls.getMethods()) {
+ if (m.isConstructor() || m.isSynthetic() || m.isStatic() || !m.isPublic()) {
+ continue;
+ }
+ String httpMethod = null;
+ List paths = null;
+ for (Map.Entry e : MAPPINGS.entrySet()) {
+ AnnotationValues values = m.getAnnotation(e.getKey());
+ if (values != null) {
+ if (httpMethod != null) {
+ ctx.error(cls, "More than one mapping annotation on "
+ + controller.binaryName + "." + m.getName());
+ return;
+ }
+ httpMethod = e.getValue();
+ paths = pathsOf(values);
+ }
+ }
+ AnnotationValues mapping = m.getAnnotation(REQUEST_MAPPING);
+ if (mapping != null) {
+ if (httpMethod != null) {
+ ctx.error(cls, "@RequestMapping and a shorthand mapping on the same "
+ + "method: " + controller.binaryName + "." + m.getName());
+ return;
+ }
+ httpMethod = mapping.getStringOrDefault("method", "GET");
+ paths = pathsOf(mapping);
+ }
+ if (httpMethod == null) {
+ continue;
+ }
+ // The verb is emitted into the router verbatim, and HttpServer
+ // answers 501 to anything outside this set BEFORE dispatch -- so a
+ // mistyped or unsupported one compiles into a branch no request can
+ // ever reach, and both the build and the running server report
+ // success while the endpoint simply does not exist. Case matters
+ // for the same reason: the server compares with equals, so "get"
+ // is not "GET".
+ if (!ROUTABLE_METHODS.contains(httpMethod)) {
+ ctx.error(cls, controller.binaryName + "." + m.getName() + " maps HTTP "
+ + "method \"" + httpMethod + "\", which the server does not route: "
+ + "the request would be answered 501 before reaching it. Use one of "
+ + ROUTABLE_METHODS + ", in upper case.");
+ return;
+ }
+ if (paths.isEmpty()) {
+ paths = Collections.singletonList("");
+ }
+ for (String base : controller.basePaths) {
+ for (String path : paths) {
+ Route route = buildRoute(cls, m, httpMethod, join(base, path), ctx);
+ if (route == null) {
+ return;
+ }
+ controller.routes.add(route);
+ }
+ }
+ }
+ if (controller.routes.isEmpty()) {
+ ctx.error(cls, "@RestController declares no mapped methods: " + controller.binaryName);
+ return;
+ }
+ if (!routeShapesAreDistinct(cls, controller, ctx)) {
+ return;
+ }
+ controllers.put(controller.binaryName, controller);
+ }
+
+ /**
+ * Refuses two routes in one controller that no request can tell apart.
+ *
+ * A variable's NAME is not part of what the matcher sees, so `GET /notes/{id}`
+ * and `GET /notes/{name}` are one shape. The generated router tests the
+ * branches in order and returns from the first, which left the second method
+ * permanently unreachable with nothing at build time or run time saying so.
+ *
+ * The shapes are held across controllers, not just within one: the bootstrap
+ * chains the routers and takes the first non-null response, so a collision
+ * between two controllers is unreachable in precisely the same way.
+ */
+ private boolean routeShapesAreDistinct(AnnotatedClass cls, Controller controller,
+ ProcessorContext ctx) {
+ for (int i = 0; i < controller.routes.size(); i++) {
+ Route route = controller.routes.get(i);
+ String shape = route.httpMethod + " " + route.pattern.replaceAll("\\{[^}]*\\}", "{}");
+ String first = routeShapes.get(shape);
+ if (first != null) {
+ ctx.error(cls, controller.binaryName + "." + route.javaMethod + " and " + first
+ + " both answer " + shape + ". A path variable's NAME is not part of "
+ + "what a request carries, so nothing can tell them apart; the routers "
+ + "are tried in order and the second can never run. Give them different "
+ + "paths, or one method.");
+ return false;
+ }
+ // Within one controller a literal route is emitted before any variable
+ // route that would swallow it -- see the comparator in generateRouter.
+ // ACROSS controllers nothing orders them: the bootstrap tries the
+ // routers in turn and takes the first non-null, so a variable route in
+ // an alphabetically earlier controller answers a literal route's own
+ // path and that method never runs. There is no ordering to fix, since
+ // a router is a set of routes rather than a single pattern, so the
+ // ambiguity is reported instead of being resolved arbitrarily.
+ String clash = crossControllerClash(controller, route, shape);
+ if (clash != null) {
+ ctx.error(cls, clash);
+ return false;
+ }
+ routeShapes.put(shape, controller.binaryName + "." + route.javaMethod);
+ routeOwners.put(shape, controller.binaryName);
+ }
+ return true;
+ }
+
+ /**
+ * Whether a route from another controller and this one can answer each other's
+ * paths, and the message saying so.
+ *
+ * Only ACROSS controllers: inside one, generateRouter's comparator already
+ * emits the literal route first.
+ */
+ private String crossControllerClash(Controller controller, Route route, String shape) {
+ String mine = controller.binaryName;
+ for (Map.Entry e : routeOwners.entrySet()) {
+ // Same controller included. Skipping it assumed generateRouter's
+ // literal-first comparator settled everything inside one class, and it
+ // does not: two DYNAMIC patterns have no dominance, so "/a/{x}/c" and
+ // "/a/b/{y}" both answer /a/b/c and whichever the sort happens to emit
+ // first wins. That is the same ambiguity as across controllers, and it
+ // has the same answer.
+ String other = e.getKey();
+ // Same verb, or they cannot collide at all -- except that a generated
+ // GET block also answers HEAD, so a GET route and a HEAD route on
+ // overlapping paths DO compete even though the verbs differ.
+ int mySpace = shape.indexOf(' ');
+ int otherSpace = other.indexOf(' ');
+ if (mySpace < 0 || otherSpace < 0) {
+ continue;
+ }
+ String myVerb = shape.substring(0, mySpace);
+ String otherVerb = other.substring(0, otherSpace);
+ boolean sameVerb = myVerb.equals(otherVerb);
+ boolean getAndHead = isGetHeadPair(myVerb, otherVerb);
+ if (!sameVerb && !getAndHead) {
+ continue;
+ }
+ if (!overlaps(other.substring(otherSpace + 1), shape.substring(mySpace + 1))) {
+ continue;
+ }
+ // Inside ONE controller, a wholly literal route and a dynamic one are
+ // resolved by generateRouter's comparator: it emits every route with
+ // no variables before every route with any, so /users/me is matched
+ // before /users/{id} and /users/42 still falls through to it. That
+ // pair is the single most ordinary thing to write, and it is what the
+ // message below tells people to do -- refusing it left no way to
+ // write it at all.
+ // Only that pair. Two DYNAMIC shapes have no dominance in that
+ // comparator, so "/a/{x}/c" against "/a/b/{y}" is still ambiguous,
+ // and two literals that overlap are the same literal twice.
+ // Within ONE controller a GET and a HEAD are ordered rather than
+ // ambiguous: generateRouter's comparator emits HEAD's own block ahead
+ // of GET's fallback, so the declared HEAD wins and the GET still
+ // answers everything else. Across controllers there is no such order
+ // -- the routers are tried in whatever sequence the bootstrap lists
+ // them -- so that pair is exactly as ambiguous as two GETs.
+ if (getAndHead && !sameVerb && mine.equals(e.getValue())) {
+ continue;
+ }
+ if (mine.equals(e.getValue()) && isLiteralShape(other) != isLiteralShape(shape)) {
+ continue;
+ }
+ return mine + "." + route.javaMethod + " answers " + shape + ", which "
+ + e.getValue() + " also answers as " + other + ". The routers are "
+ + "tried one after another, so whichever controller happens to "
+ + "come first takes the request and the other method never runs. "
+ + "Put both routes in one controller, where the more specific one "
+ + "is matched first, or give them different paths.";
+ }
+ return null;
+ }
+
+ /**
+ * The class for an internal name as it appears on the COMPILE CLASSPATH,
+ * whether that is a directory of classes or a jar, or null when it is on
+ * neither. Read with ASM rather than loaded: a build must not run a
+ * dependency's static initialisers to answer a question about its shape.
+ */
+ private static AnnotatedClass fromCompileClasspath(ProcessorContext ctx, String internalName) {
+ String entryName = internalName + ".class";
+ for (String element : ctx.getCompileClasspath()) {
+ File file = new File(element);
+ if (file.isDirectory()) {
+ File candidate = new File(file, entryName.replace('/', File.separatorChar));
+ if (candidate.isFile()) {
+ try {
+ return ClassScanner.readClass(candidate);
+ } catch (Exception err) {
+ return null;
+ }
+ }
+ continue;
+ }
+ if (!file.isFile()) {
+ continue;
+ }
+ try {
+ ZipFile zip = new ZipFile(file);
+ try {
+ ZipEntry entry = zip.getEntry(entryName);
+ if (entry != null) {
+ InputStream in = zip.getInputStream(entry);
+ try {
+ return ClassScanner.readClass(in, file);
+ } finally {
+ in.close();
+ }
+ }
+ } finally {
+ zip.close();
+ }
+ } catch (Exception err) {
+ continue; // an unreadable entry is not an answer
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Whether these two verbs are the GET/HEAD pair, in either order.
+ *
+ * They are not the same verb, but they answer the same requests: a generated
+ * GET block accepts HEAD, which is what makes a controller with only
+ * @GetMapping usable by a health check.
+ */
+ private static boolean isGetHeadPair(String a, String b) {
+ return ("GET".equals(a) && "HEAD".equals(b)) || ("HEAD".equals(a) && "GET".equals(b));
+ }
+
+ /** The descriptor the scanner keys @Generated by. */
+ private static final String GENERATED = PKG + "Generated;";
+
+ /**
+ * Whether a class of this name exists AND is somebody else's.
+ *
+ * The name being taken is not enough. An incremental build -- process-classes
+ * a second time, without a clean -- scans target/classes and finds the router
+ * this processor wrote on the FIRST pass, so an unconditional lookup reported
+ * the processor's own output as a class it would overwrite. Every project
+ * using @RestController failed its second build, and only a clean fixed it.
+ * Generated classes carry the marker for exactly this reason; a real
+ * user-defined collision has no marker and is still refused.
+ */
+ private static boolean isNotOurOwnOutput(ProcessorContext ctx, String binaryName) {
+ AnnotatedClass existing = ctx.lookup(binaryName.replace('.', '/'));
+ return existing != null && !existing.getClassAnnotations().containsKey(GENERATED);
+ }
+
+ /** HEAD sorts ahead of everything, so its own block precedes GET's fallback. */
+ private static int methodRank(String httpMethod) {
+ return "HEAD".equals(httpMethod) ? 0 : 1;
+ }
+
+ /** A shape with no variables at all, which the router matches before any. */
+ private static boolean isLiteralShape(String shape) {
+ return shape.indexOf('{') < 0;
+ }
+
+ /**
+ * Whether one path can satisfy both shapes.
+ *
+ * Two patterns that BOTH hold variables can still collide: "/a/{x}/c" and
+ * "/a/b/{y}" are different shapes, and "/a/b/c" is answered by either. An
+ * earlier version compared a variable pattern only against a literal one and
+ * returned early whenever both had a variable, which is exactly the case this
+ * misses. Segment by segment instead: a variable segment matches any single
+ * segment, so two shapes overlap when they have the same number of segments
+ * and every pair of segments is compatible.
+ */
+ private static boolean overlaps(String left, String right) {
+ String[] a = left.split("/", -1);
+ String[] b = right.split("/", -1);
+ if (a.length != b.length) {
+ return false;
+ }
+ for (int i = 0; i < a.length; i++) {
+ if (!segmentsOverlap(a[i], b[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Whether two single segments can be the same text.
+ *
+ * A segment is a literal, a whole variable, or a variable with literal text
+ * around it ("{}.json"). Two segments that both contain a variable overlap
+ * only when their fixed edges permit it; anything less certain errs toward
+ * reporting an ambiguity rather than shipping one.
+ */
+ static boolean segmentsOverlap(String left, String right) {
+ boolean leftVar = left.indexOf("{}") >= 0;
+ boolean rightVar = right.indexOf("{}") >= 0;
+ if (!leftVar && !rightVar) {
+ return left.equals(right);
+ }
+ if (leftVar && rightVar) {
+ // The fixed EDGES decide it. Both carry a variable, but "{}.json" and
+ // "{}.xml" cannot both match one segment, and returning true here
+ // refused a controller that could never be ambiguous -- while the
+ // matcher this check guards handles literals around a variable
+ // perfectly well. A segment matching both must start with both
+ // prefixes and end with both suffixes, which is only possible when one
+ // of each pair contains the other.
+ String leftPrefix = left.substring(0, left.indexOf("{}"));
+ String rightPrefix = right.substring(0, right.indexOf("{}"));
+ String leftSuffix = left.substring(left.lastIndexOf("{}") + 2);
+ String rightSuffix = right.substring(right.lastIndexOf("{}") + 2);
+ boolean prefixesAgree = leftPrefix.startsWith(rightPrefix)
+ || rightPrefix.startsWith(leftPrefix);
+ boolean suffixesAgree = leftSuffix.endsWith(rightSuffix)
+ || rightSuffix.endsWith(leftSuffix);
+ return prefixesAgree && suffixesAgree;
+ }
+ String pattern = leftVar ? left : right;
+ String literal = leftVar ? right : left;
+ StringBuilder regex = new StringBuilder();
+ for (int i = 0; i < pattern.length(); i++) {
+ if (pattern.startsWith("{}", i)) {
+ regex.append("[^/]+");
+ i++;
+ } else {
+ char c = pattern.charAt(i);
+ if ("\\.[]{}()*+-?^$|".indexOf(c) >= 0) {
+ regex.append('\\');
+ }
+ regex.append(c);
+ }
+ }
+ return literal.matches(regex.toString());
+ }
+
+ /**
+ * Splits a route pattern into the parts the generated matcher needs.
+ *
+ * `/notes/{id}/tags` becomes prefix `/notes/` and one variable followed by
+ * `/tags`. The prefix is what the byte compare rejects on, which is most
+ * requests for most routes, and it is why the split happens here rather than at
+ * request time.
+ */
+ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, String pattern,
+ ProcessorContext ctx) {
+ Route route = new Route();
+ route.httpMethod = httpMethod;
+ route.pattern = pattern.length() == 0 ? "/" : pattern;
+ route.javaMethod = m.getName();
+
+ int firstVar = route.pattern.indexOf('{');
+ route.prefix = firstVar < 0 ? route.pattern : route.pattern.substring(0, firstVar);
+ List variableNames = new ArrayList();
+ int pos = firstVar;
+ while (pos >= 0) {
+ int close = route.pattern.indexOf('}', pos);
+ if (close < 0) {
+ ctx.error(cls, "Unclosed '{' in route " + route.pattern + " on "
+ + cls.getBinaryName() + "." + m.getName());
+ return null;
+ }
+ variableNames.add(route.pattern.substring(pos + 1, close));
+ int next = route.pattern.indexOf('{', close);
+ String following = next < 0 ? route.pattern.substring(close + 1)
+ : route.pattern.substring(close + 1, next);
+ // Two variables with nothing between them cannot be split. There is
+ // no text to look for, so the matcher gives the first one everything
+ // that is left and then fails because a second is still owed --
+ // meaning the route compiles and then answers 404 to every request,
+ // which is the worst way to be wrong. Nothing can bind it, so it is
+ // refused where it is written.
+ if (following.length() == 0 && next >= 0) {
+ ctx.error(cls, cls.getBinaryName() + "." + m.getName() + " declares the "
+ + "route " + route.pattern + ", where two variables are adjacent. "
+ + "Nothing separates them, so no request could ever match it. Put a "
+ + "literal between them, such as a '/' or a '-'.");
+ return null;
+ }
+ route.after.add(following);
+ pos = next;
+ }
+
+ Type[] paramTypes = Type.getArgumentTypes(m.getDescriptor());
+ String[] genericParams = RestClientAnnotationProcessor.parseGenericParameterSignatures(
+ m.getSignature(), paramTypes.length);
+ List