From 27142931d75144650c0e4d654b6c838fdc82e52c Mon Sep 17 00:00:00 2001 From: Mathieu Baudier Date: Tue, 24 Sep 2024 14:08:12 +0200 Subject: [PATCH] First implementation of plain Java WebSocket support --- .../api/cms/http/WebSocketHttpServer.java | 8 ++ .../cms/jetty/AbstractJettyHttpContext.java | 1 + .../org/argeo/cms/jetty/JettyHttpServer.java | 14 +++ .../argeo/cms/jetty/ee10/CmsJettyServer.java | 2 +- .../HttpContextJettyContextHandler.java | 2 +- .../cms/jetty/server/JettyHttpContext.java | 34 ++++++- .../jetty/websocket/JettyJavaWebSocket.java | 83 +++++++++++++++++ .../jetty/websocket/JettyLocalWebSocket.java | 91 +++++++++++++++++++ .../JettyServerWebSocketFactory.java | 61 +++++++++++++ org.argeo.cms/OSGI-INF/pingWebSocket.xml | 10 ++ org.argeo.cms/bnd.bnd | 1 + .../org/argeo/cms/client/WebSocketPing.java | 18 ++-- .../cms/internal/http/PingWebSocket.java | 27 ++++++ .../OSGI-INF/equinoxJettyServer.xml | 1 + .../OSGI-INF/jettyServerWebSocketFactory.xml | 6 ++ .../equinox/org.argeo.cms.lib.equinox/bnd.bnd | 2 + .../build.properties | 5 +- 17 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 org.argeo.api.cms/src/org/argeo/api/cms/http/WebSocketHttpServer.java create mode 100644 org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyJavaWebSocket.java create mode 100644 org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyLocalWebSocket.java create mode 100644 org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyServerWebSocketFactory.java create mode 100644 org.argeo.cms/OSGI-INF/pingWebSocket.xml create mode 100644 org.argeo.cms/src/org/argeo/cms/internal/http/PingWebSocket.java create mode 100644 osgi/equinox/org.argeo.cms.lib.equinox/OSGI-INF/jettyServerWebSocketFactory.xml diff --git a/org.argeo.api.cms/src/org/argeo/api/cms/http/WebSocketHttpServer.java b/org.argeo.api.cms/src/org/argeo/api/cms/http/WebSocketHttpServer.java new file mode 100644 index 000000000..d915e3fef --- /dev/null +++ b/org.argeo.api.cms/src/org/argeo/api/cms/http/WebSocketHttpServer.java @@ -0,0 +1,8 @@ +package org.argeo.api.cms.http; + +import java.net.http.WebSocket; + +public interface WebSocketHttpServer { + + WebSocket.Builder newWebSocketBuilder() ; +} diff --git a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/AbstractJettyHttpContext.java b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/AbstractJettyHttpContext.java index 75b3c1a67..f0c2a8092 100644 --- a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/AbstractJettyHttpContext.java +++ b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/AbstractJettyHttpContext.java @@ -1,5 +1,6 @@ package org.argeo.cms.jetty; +import java.net.http.WebSocket; import java.util.ArrayList; import java.util.List; import java.util.Objects; diff --git a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/JettyHttpServer.java b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/JettyHttpServer.java index ae647fa74..4f5827df2 100644 --- a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/JettyHttpServer.java +++ b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/JettyHttpServer.java @@ -33,6 +33,7 @@ import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.ExecutorThreadPool; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.ThreadPool; +import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; import com.sun.net.httpserver.HttpContext; import com.sun.net.httpserver.HttpHandler; @@ -71,6 +72,8 @@ public class JettyHttpServer extends HttpsServer { private boolean started; private CmsState cmsState; + + private WebSocketUpgradeHandler webSocketUpgradeHandler; @Override public void bind(InetSocketAddress addr, int backlog) throws IOException { @@ -122,6 +125,9 @@ public class JettyHttpServer extends HttpsServer { if (rootHandler != null) configureRootHandler(rootHandler); + webSocketUpgradeHandler = WebSocketUpgradeHandler.from(server); + pathMappingsHandler.addMapping(PathSpec.from("/ws/*"), webSocketUpgradeHandler); + // if (rootContextHandler != null && !contexts.containsKey("/")) // contextHandlerCollection.addHandler(rootContextHandler); // server.setHandler(contextHandlerCollection); @@ -410,6 +416,14 @@ public class JettyHttpServer extends HttpsServer { throw new UnsupportedOperationException(); } + public Server getServer() { + return server; + } + + public WebSocketUpgradeHandler getWebSocketUpgradeHandler() { + return webSocketUpgradeHandler; + } + public static void main(String... args) { JettyHttpServer httpServer = new JettyHttpServer(); System.setProperty("argeo.http.port", "8080"); diff --git a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/ee10/CmsJettyServer.java b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/ee10/CmsJettyServer.java index 5fb7ebdf6..5ec30b2f2 100644 --- a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/ee10/CmsJettyServer.java +++ b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/ee10/CmsJettyServer.java @@ -45,7 +45,7 @@ public class CmsJettyServer extends JettyHttpServer { Thread.currentThread().getContextClassLoader()); servletContextHandler.setClassLoader(this.getClass().getClassLoader()); servletContextHandler.setContextPath("/"); - //servletContextHandler.setContextPath("/cms/user"); + // servletContextHandler.setContextPath("/cms/user"); servletContextHandler.setAttribute(CONTEXT_TEMPDIR, tempDir.toAbsolutePath().toFile()); SessionHandler handler = new SessionHandler(); diff --git a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/server/HttpContextJettyContextHandler.java b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/server/HttpContextJettyContextHandler.java index 26b33c640..c8458e6a8 100644 --- a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/server/HttpContextJettyContextHandler.java +++ b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/server/HttpContextJettyContextHandler.java @@ -9,7 +9,7 @@ class HttpContextJettyContextHandler extends ContextHandler { public HttpContextJettyContextHandler(HttpContext httpContext) { // FIXME make path more robust - super(new HttpContextJettyHandler(httpContext), httpContext.getPath() + "/*"); + super(new HttpContextJettyHandler(httpContext), null); } } diff --git a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/server/JettyHttpContext.java b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/server/JettyHttpContext.java index e530c310a..9c1b56d33 100644 --- a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/server/JettyHttpContext.java +++ b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/server/JettyHttpContext.java @@ -1,13 +1,20 @@ package org.argeo.cms.jetty.server; +import java.net.http.WebSocket; import java.util.HashMap; import java.util.Map; import org.argeo.cms.jetty.AbstractJettyHttpContext; import org.argeo.cms.jetty.ContextHandlerAttributes; import org.argeo.cms.jetty.JettyHttpServer; +import org.argeo.cms.jetty.websocket.JettyLocalWebSocket; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.websocket.server.ServerUpgradeRequest; +import org.eclipse.jetty.websocket.server.ServerUpgradeResponse; +import org.eclipse.jetty.websocket.server.WebSocketCreator; +import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; import com.sun.net.httpserver.HttpContext; @@ -16,7 +23,7 @@ import com.sun.net.httpserver.HttpContext; * the jakarta/javax servlet APIs). */ public class JettyHttpContext extends AbstractJettyHttpContext { - private final Handler handler; + private final Handler httpHandler; private Map attributes; public JettyHttpContext(JettyHttpServer httpServer, String path) { @@ -25,17 +32,34 @@ public class JettyHttpContext extends AbstractJettyHttpContext { if (useContextHandler) { // TODO not working yet // (sub contexts do not work) - handler = new HttpContextJettyContextHandler(this); - attributes = new ContextHandlerAttributes((ContextHandler) handler); + httpHandler = new HttpContextJettyContextHandler(this); + attributes = new ContextHandlerAttributes((ContextHandler) httpHandler); } else { - handler = new HttpContextJettyHandler(this); + httpHandler = new HttpContextJettyHandler(this); attributes = new HashMap<>(); } } @Override protected Handler getJettyHandler() { - return handler; + WebSocketUpgradeHandler webSocketUpgradeHandler = WebSocketUpgradeHandler.from(getJettyHttpServer().getServer(), + (container) -> { + container.addMapping(getPath(), new WebSocketCreator() { + + @Override + public Object createWebSocket(ServerUpgradeRequest upgradeRequest, + ServerUpgradeResponse upgradeResponse, Callback callback) throws Exception { + if (getHandler() instanceof WebSocket.Listener webSocketListener) { + return new JettyLocalWebSocket(webSocketListener); + } else { + callback.succeeded(); + return null; + } + } + }); + }); + webSocketUpgradeHandler.setHandler(httpHandler); + return webSocketUpgradeHandler; } @Override diff --git a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyJavaWebSocket.java b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyJavaWebSocket.java new file mode 100644 index 000000000..e7cf5363a --- /dev/null +++ b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyJavaWebSocket.java @@ -0,0 +1,83 @@ +package org.argeo.cms.jetty.websocket; + +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.jetty.websocket.api.Callback; +import org.eclipse.jetty.websocket.api.Session; + +/** + * A {@link java.net.http.WebSocket} wrapping a Jetty WebSocket API + * {@link Session}. This is the "client" interface of a server-side socket, + * which allows to interact with the remote endpoint. + */ +class JettyJavaWebSocket implements WebSocket { + private Session session; + + JettyJavaWebSocket(Session session) { + this.session = session; + } + + @Override + public CompletableFuture sendText(CharSequence data, boolean last) { + return Callback.Completable.with(completable -> session.sendText(data.toString(), completable)) + .thenApply((v) -> JettyJavaWebSocket.this); + } + + @Override + public CompletableFuture sendBinary(ByteBuffer data, boolean last) { + return Callback.Completable.with(completable -> session.sendBinary(data, completable)) + .thenApply((v) -> JettyJavaWebSocket.this); + } + + @Override + public CompletableFuture sendPing(ByteBuffer message) { + return Callback.Completable.with(completable -> session.sendPing(message, completable)) + .thenApply((v) -> JettyJavaWebSocket.this); + } + + @Override + public CompletableFuture sendPong(ByteBuffer message) { + return Callback.Completable.with(completable -> session.sendPong(message, completable)) + .thenApply((v) -> JettyJavaWebSocket.this); + } + + @Override + public CompletableFuture sendClose(int statusCode, String reason) { + return Callback.Completable.with(completable -> session.close(statusCode, reason, completable)) + .thenApply((v) -> JettyJavaWebSocket.this); + } + + @Override + public void request(long n) { + for (long i = 0; i < n; i++) { + // TODO throttle it somehow? + session.demand(); + } + } + + @Override + public String getSubprotocol() { + // TODO test this + return session.getUpgradeResponse().getAcceptedSubProtocol(); + } + + @Override + public boolean isOutputClosed() { + // TODO make sure the semantics are similar + return !session.isOpen(); + } + + @Override + public boolean isInputClosed() { + // TODO make sure the semantics are similar + return !session.isOpen(); + } + + @Override + public void abort() { + session.disconnect(); + } + +} diff --git a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyLocalWebSocket.java b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyLocalWebSocket.java new file mode 100644 index 000000000..51f5baa57 --- /dev/null +++ b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyLocalWebSocket.java @@ -0,0 +1,91 @@ +package org.argeo.cms.jetty.websocket; + +import java.net.http.WebSocket; +import java.net.http.WebSocket.Listener; +import java.nio.ByteBuffer; +import java.util.Objects; +import java.util.concurrent.CompletionStage; + +import org.eclipse.jetty.websocket.api.Callback; +import org.eclipse.jetty.websocket.api.Frame; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketFrame; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen; + +/** + * Wrap a {@link java.net.http.WebSocket.Listener} with Jetty WebSocket API + * annotations. This is the actual "server"/local side of a WebSocket. + */ +@org.eclipse.jetty.websocket.api.annotations.WebSocket(autoDemand = false) +public class JettyLocalWebSocket { + private WebSocket.Listener listener; + + public JettyLocalWebSocket(Listener listener) { + this.listener = listener; + } + + @OnWebSocketOpen + public void onOpen(Session session) { + //session.demand(); + listener.onOpen(wrap(session)); + } + + @OnWebSocketMessage + public void onText(Session session, String text, boolean last) { + waitFor(listener.onText(wrap(session), text, last)); + } + + @OnWebSocketMessage + public void onBinary(Session session, ByteBuffer data, boolean last, Callback callback) { + notifyCallback(listener.onBinary(wrap(session), data, last), callback); + } + + @OnWebSocketFrame + public void onFrame(Session session, Frame frame, Callback callback) { + if (Frame.Type.PING.equals(frame.getType())) { + notifyCallback(listener.onPing(wrap(session), frame.getPayload()), callback); + } else if (Frame.Type.PONG.equals(frame.getType())) { + notifyCallback(listener.onPong(wrap(session), frame.getPayload()), callback); + } + } + + @OnWebSocketClose + public void onClose(Session session, int statusCode, String reason) { + waitFor(listener.onClose(wrap(session), statusCode, reason)); + } + + @OnWebSocketError + public void onError(Session session, Throwable error) { + listener.onError(wrap(session), error); + } + + /* + * UTILITIES + */ + protected WebSocket wrap(Session session) { + return new JettyJavaWebSocket(session); + } + + protected void waitFor(CompletionStage stage) { + if (stage == null) + return; + stage.toCompletableFuture().join(); + } + + protected void notifyCallback(CompletionStage stage, Callback callback) { + Objects.requireNonNull(callback); + if (stage == null) { + callback.succeed(); + return; + } + stage.exceptionally((t) -> {// failure + callback.fail(t); + return null; + }).thenRun(() -> {// success + callback.succeed(); + }); + } +} diff --git a/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyServerWebSocketFactory.java b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyServerWebSocketFactory.java new file mode 100644 index 000000000..fdfb8fd4b --- /dev/null +++ b/org.argeo.cms.lib.jetty/src/org/argeo/cms/jetty/websocket/JettyServerWebSocketFactory.java @@ -0,0 +1,61 @@ +package org.argeo.cms.jetty.websocket; + +import static org.argeo.api.cms.CmsConstants.CONTEXT_PATH; + +import java.net.http.WebSocket; +import java.util.Map; + +import org.argeo.api.cms.CmsConstants; +import org.argeo.api.cms.CmsLog; +import org.argeo.cms.jetty.JettyHttpServer; +import org.eclipse.jetty.websocket.server.ServerWebSocketContainer; +import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; + +/** Adds WebSocket mapping to an existing Jetty server. */ +public class JettyServerWebSocketFactory { + private final static CmsLog log = CmsLog.getLog(JettyServerWebSocketFactory.class); + + private ServerWebSocketContainer container; + + public void setJettyHttpServer(JettyHttpServer jettyHttpServer) { + //ServletContextHandler contextHandler = (ServletContextHandler) jettyHttpServer.getRootHandler(); + //container = ServerWebSocketContainer.ensure(jettyHttpServer.getServer(), contextHandler); + WebSocketUpgradeHandler webSocketUpgradeHandler = jettyHttpServer.getWebSocketUpgradeHandler(); + container = webSocketUpgradeHandler.getServerWebSocketContainer(); +// container = ServerWebSocketContainer.ensure(jettyHttpServer.getServer()); + log.debug("WebSocket support initalized"); + } + + public void addWebSocket(WebSocket.Listener webSocket, Map properties) { + String path = properties.get(CmsConstants.CONTEXT_PATH); + if (path == null) { + log.warn("Property " + CONTEXT_PATH + " not set on HTTP handler " + properties + ". Ignoring it."); + return; + } + + container.addMapping(path, (upgradeRequest, upgradeResponse, callback) -> { + log.debug("Adding " + path + " WebSocket " + webSocket.getClass()); + return new JettyLocalWebSocket(webSocket); + }); + } + + public void removeWebSocket(WebSocket.Listener webSocket, Map properties) { + String path = properties.get(CmsConstants.CONTEXT_PATH); + if (path == null) { + log.warn("Property " + CONTEXT_PATH + " not set on HTTP handler " + properties + ". Ignoring it."); + return; + } + + container.addMapping(path, (upgradeRequest, upgradeResponse, callback) -> { + // disable web socket for this path + log.debug("Removing " + path + " WebSocket " + webSocket.getClass()); + // TODO check that it works and that mappings can be removed dynamically + callback.succeeded(); + return null; + }); + } + + public static void main(String[] args) { + + } +} diff --git a/org.argeo.cms/OSGI-INF/pingWebSocket.xml b/org.argeo.cms/OSGI-INF/pingWebSocket.xml new file mode 100644 index 000000000..784e24f9a --- /dev/null +++ b/org.argeo.cms/OSGI-INF/pingWebSocket.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/org.argeo.cms/bnd.bnd b/org.argeo.cms/bnd.bnd index 3fd92c226..3f47ea1fc 100644 --- a/org.argeo.cms/bnd.bnd +++ b/org.argeo.cms/bnd.bnd @@ -21,4 +21,5 @@ OSGI-INF/cmsContext.xml,\ OSGI-INF/cmsFileSystemProvider.xml,\ OSGI-INF/cmsAcrHttpHandler.xml,\ OSGI-INF/pkgHttpHandler.xml,\ +OSGI-INF/pingWebSocket.xml,\ diff --git a/org.argeo.cms/src/org/argeo/cms/client/WebSocketPing.java b/org.argeo.cms/src/org/argeo/cms/client/WebSocketPing.java index 808c8de68..b2234de9b 100644 --- a/org.argeo.cms/src/org/argeo/cms/client/WebSocketPing.java +++ b/org.argeo.cms/src/org/argeo/cms/client/WebSocketPing.java @@ -77,14 +77,14 @@ public class WebSocketPing implements Runnable { } } -// public static void main(String[] args) throws Exception { -// if (args.length == 0) { -// System.err.println("usage: java " + WsPing.class.getName() + " "); -// System.exit(1); -// return; -// } -// URI uri = URI.create(args[0]); -// new WsPing(uri).run(); -// } + public static void main(String[] args) throws Exception { + if (args.length == 0) { + System.err.println("usage: java " + WebSocketPing.class.getName() + " "); + System.exit(1); + return; + } + URI uri = URI.create(args[0]); + new WebSocketPing(uri).run(); + } } diff --git a/org.argeo.cms/src/org/argeo/cms/internal/http/PingWebSocket.java b/org.argeo.cms/src/org/argeo/cms/internal/http/PingWebSocket.java new file mode 100644 index 000000000..3cb033281 --- /dev/null +++ b/org.argeo.cms/src/org/argeo/cms/internal/http/PingWebSocket.java @@ -0,0 +1,27 @@ +package org.argeo.cms.internal.http; + +import java.io.IOException; +import java.net.http.WebSocket; +import java.net.http.WebSocket.Listener; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletionStage; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +/** A trivial ping WebSocket. */ +public class PingWebSocket implements Listener, HttpHandler { + + @Override + public CompletionStage onPing(WebSocket webSocket, ByteBuffer message) { + return null; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + exchange.sendResponseHeaders(200, -1); + exchange.getResponseBody().write("pong".getBytes()); + exchange.getResponseBody().close(); + } + +} diff --git a/osgi/equinox/org.argeo.cms.lib.equinox/OSGI-INF/equinoxJettyServer.xml b/osgi/equinox/org.argeo.cms.lib.equinox/OSGI-INF/equinoxJettyServer.xml index 1b75ea2a4..95caf37a4 100644 --- a/osgi/equinox/org.argeo.cms.lib.equinox/OSGI-INF/equinoxJettyServer.xml +++ b/osgi/equinox/org.argeo.cms.lib.equinox/OSGI-INF/equinoxJettyServer.xml @@ -6,5 +6,6 @@ + diff --git a/osgi/equinox/org.argeo.cms.lib.equinox/OSGI-INF/jettyServerWebSocketFactory.xml b/osgi/equinox/org.argeo.cms.lib.equinox/OSGI-INF/jettyServerWebSocketFactory.xml new file mode 100644 index 000000000..b7268f5a9 --- /dev/null +++ b/osgi/equinox/org.argeo.cms.lib.equinox/OSGI-INF/jettyServerWebSocketFactory.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/osgi/equinox/org.argeo.cms.lib.equinox/bnd.bnd b/osgi/equinox/org.argeo.cms.lib.equinox/bnd.bnd index ea6be5c9e..e9bbbf36b 100644 --- a/osgi/equinox/org.argeo.cms.lib.equinox/bnd.bnd +++ b/osgi/equinox/org.argeo.cms.lib.equinox/bnd.bnd @@ -1,7 +1,9 @@ Service-Component: \ OSGI-INF/equinoxJettyServer.xml,\ +OSGI-INF/jettyServerWebSocketFactory.xml,\ Import-Package:\ org.eclipse.jetty.session,\ org.eclipse.jetty.server,\ +org.argeo.cms.jetty.websocket,\ * diff --git a/osgi/equinox/org.argeo.cms.lib.equinox/build.properties b/osgi/equinox/org.argeo.cms.lib.equinox/build.properties index 34d2e4d2d..9c340c298 100644 --- a/osgi/equinox/org.argeo.cms.lib.equinox/build.properties +++ b/osgi/equinox/org.argeo.cms.lib.equinox/build.properties @@ -1,4 +1,5 @@ +bin.includes = META-INF/,\ + .,\ + OSGI-INF/jettyServerWebSocketFactory.xml source.. = src/ output.. = bin/ -bin.includes = META-INF/,\ - . -- 2.39.5