diff --git a/.idea/encodings.xml b/.idea/encodings.xml
index c502d2d4d072..2281e7d3ce2d 100644
--- a/.idea/encodings.xml
+++ b/.idea/encodings.xml
@@ -29,5 +29,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/bom/pom.xml b/bom/pom.xml
index 6192bd3f6889..6b8d1b3c6246 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -296,6 +296,11 @@ THE SOFTWARE.
j-interop
2.0.8-kohsuke-1
+
+ org.kohsuke.metainf-services
+ metainf-services
+ 1.9
+
org.kohsuke.stapler
json-lib
diff --git a/core/pom.xml b/core/pom.xml
index 507b01b921da..fe1415c82182 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -328,6 +328,17 @@ THE SOFTWARE.
org.jenkins-ci
version-number
+
+ org.jenkins-ci.main
+ websocket-spi
+ ${project.version}
+
+
+ javax.servlet
+ javax.servlet-api
+
+
+
org.jfree
jfreechart
@@ -375,10 +386,8 @@ THE SOFTWARE.
j-interop
-
org.kohsuke.metainf-services
metainf-services
- 1.9
true
diff --git a/core/src/main/java/jenkins/websocket/WebSocketSession.java b/core/src/main/java/jenkins/websocket/WebSocketSession.java
index 341be5ea71c5..f40c083a4bc4 100644
--- a/core/src/main/java/jenkins/websocket/WebSocketSession.java
+++ b/core/src/main/java/jenkins/websocket/WebSocketSession.java
@@ -25,7 +25,6 @@
package jenkins.websocket;
import java.io.IOException;
-import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
@@ -60,48 +59,28 @@ public abstract class WebSocketSession {
private static final Logger LOGGER = Logger.getLogger(WebSocketSession.class.getName());
- private Object session;
- // https://www.eclipse.org/jetty/javadoc/9.4.24.v20191120/org/eclipse/jetty/websocket/common/WebSocketRemoteEndpoint.html
- private Object remoteEndpoint;
+ Provider.Handler handler;
private ScheduledFuture> pings;
protected WebSocketSession() {}
- Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws Exception {
- switch (method.getName()) {
- case "onWebSocketConnect":
- this.session = args[0];
- this.remoteEndpoint = session.getClass().getMethod("getRemote").invoke(args[0]);
- if (PING_INTERVAL_SECONDS != 0) {
- pings = Timer.get().scheduleAtFixedRate(() -> {
- try {
- remoteEndpoint.getClass().getMethod("sendPing", ByteBuffer.class).invoke(remoteEndpoint, ByteBuffer.wrap(new byte[0]));
- } catch (Exception x) {
- error(x);
- pings.cancel(true);
- }
- }, PING_INTERVAL_SECONDS / 2, PING_INTERVAL_SECONDS, TimeUnit.SECONDS);
- }
- opened();
- return null;
- case "onWebSocketClose":
- if (pings != null) {
- pings.cancel(true);
- // alternately, check Session.isOpen each time
- }
- closed((Integer) args[0], (String) args[1]);
- return null;
- case "onWebSocketError":
- error((Throwable) args[0]);
- return null;
- case "onWebSocketBinary":
- binary((byte[]) args[0], (Integer) args[1], (Integer) args[2]);
- return null;
- case "onWebSocketText":
- text((String) args[0]);
- return null;
- default:
- throw new AssertionError();
+ void startPings() {
+ if (PING_INTERVAL_SECONDS != 0) {
+ pings = Timer.get().scheduleAtFixedRate(() -> {
+ try {
+ handler.sendPing(ByteBuffer.wrap(new byte[0]));
+ } catch (Exception x) {
+ error(x);
+ pings.cancel(true);
+ }
+ }, PING_INTERVAL_SECONDS / 2, PING_INTERVAL_SECONDS, TimeUnit.SECONDS);
+ }
+ }
+
+ void stopPings() {
+ if (pings != null) {
+ pings.cancel(true);
+ // alternately, check Session.isOpen each time
}
}
@@ -123,38 +102,20 @@ protected void text(String message) throws IOException {
LOGGER.warning("unexpected text frame");
}
- @SuppressWarnings("unchecked")
protected final Future sendBinary(ByteBuffer data) throws IOException {
- try {
- return (Future) remoteEndpoint.getClass().getMethod("sendBytesByFuture", ByteBuffer.class).invoke(remoteEndpoint, data);
- } catch (Exception x) {
- throw new IOException(x);
- }
+ return handler.sendBinary(data);
}
protected final void sendBinary(ByteBuffer partialByte, boolean isLast) throws IOException {
- try {
- remoteEndpoint.getClass().getMethod("sendPartialBytes", ByteBuffer.class, boolean.class).invoke(remoteEndpoint, partialByte, isLast);
- } catch (Exception x) {
- throw new IOException(x);
- }
+ handler.sendBinary(partialByte, isLast);
}
- @SuppressWarnings("unchecked")
protected final Future sendText(String text) throws IOException {
- try {
- return (Future) remoteEndpoint.getClass().getMethod("sendStringByFuture", String.class).invoke(remoteEndpoint, text);
- } catch (Exception x) {
- throw new IOException(x);
- }
+ return handler.sendText(text);
}
protected final void close() throws IOException {
- try {
- session.getClass().getMethod("close").invoke(session);
- } catch (Exception x) {
- throw new IOException(x);
- }
+ handler.close();
}
}
diff --git a/core/src/main/java/jenkins/websocket/WebSockets.java b/core/src/main/java/jenkins/websocket/WebSockets.java
index 91d80c8c5f93..aafabcd79c9d 100644
--- a/core/src/main/java/jenkins/websocket/WebSockets.java
+++ b/core/src/main/java/jenkins/websocket/WebSockets.java
@@ -24,99 +24,101 @@
package jenkins.websocket;
-import hudson.Extension;
-import hudson.ExtensionList;
-import java.lang.reflect.Method;
-import java.lang.reflect.Proxy;
+import java.io.IOException;
+import java.nio.channels.ClosedChannelException;
+import java.util.Iterator;
+import java.util.ServiceConfigurationError;
+import java.util.ServiceLoader;
import java.util.logging.Level;
import java.util.logging.Logger;
-import javax.servlet.ServletContext;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
-import org.kohsuke.stapler.Stapler;
/**
* Support for serving WebSocket responses.
* @since 2.216
*/
@Restricted(Beta.class)
-@Extension
public class WebSockets {
private static final Logger LOGGER = Logger.getLogger(WebSockets.class.getName());
- private static final String ATTR_SESSION = WebSockets.class.getName() + ".session";
+ private static final Provider provider = findProvider();
+
+ private static Provider findProvider() {
+ Iterator it = ServiceLoader.load(Provider.class).iterator();
+ while (it.hasNext()) {
+ try {
+ return it.next();
+ } catch (ServiceConfigurationError x) {
+ LOGGER.log(Level.FINE, null, x);
+ }
+ }
+ return null;
+ }
// TODO ability to handle subprotocols?
public static HttpResponse upgrade(WebSocketSession session) {
+ if (provider == null) {
+ throw HttpResponses.notFound();
+ }
return (req, rsp, node) -> {
try {
- Object factory = ExtensionList.lookupSingleton(WebSockets.class).init();
- if (!((Boolean) webSocketServletFactoryClass.getMethod("isUpgradeRequest", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) {
- throw HttpResponses.errorWithoutStack(HttpServletResponse.SC_BAD_REQUEST, "only WS connections accepted here");
- }
- req.setAttribute(ATTR_SESSION, session);
- if (!((Boolean) webSocketServletFactoryClass.getMethod("acceptWebSocket", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) {
- throw HttpResponses.errorWithoutStack(HttpServletResponse.SC_BAD_REQUEST, "did not manage to upgrade");
- }
- } catch (HttpResponses.HttpResponseException x) {
- throw x;
+ session.handler = provider.handle(req, rsp, new Provider.Listener() {
+ @Override
+ public void onWebSocketConnect() {
+ session.startPings();
+ session.opened();
+ }
+
+ @Override
+ public void onWebSocketClose(int statusCode, String reason) {
+ session.stopPings();
+ session.closed(statusCode, reason);
+ }
+
+ @Override
+ public void onWebSocketError(Throwable cause) {
+ if (cause instanceof ClosedChannelException) {
+ onWebSocketClose(0, cause.toString());
+ } else {
+ session.error(cause);
+ }
+ }
+
+ @Override
+ public void onWebSocketBinary(byte[] payload, int offset, int length) {
+ try {
+ session.binary(payload, offset, length);
+ } catch (IOException x) {
+ session.error(x);
+ }
+ }
+
+ @Override
+ public void onWebSocketText(String message) {
+ try {
+ session.text(message);
+ } catch (IOException x) {
+ session.error(x);
+ }
+ }
+ });
} catch (Exception x) {
LOGGER.log(Level.WARNING, null, x);
throw HttpResponses.error(x);
}
- // OK!
+ // OK, unless handler is null in which case we expect an error was already sent.
};
}
- private static ClassLoader cl;
- private static Class> webSocketServletFactoryClass;
-
- private static synchronized void staticInit() throws Exception {
- if (webSocketServletFactoryClass == null) {
- cl = ServletContext.class.getClassLoader();
- webSocketServletFactoryClass = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketServletFactory");
- }
- }
-
public static boolean isSupported() {
- try {
- staticInit();
- return true;
- } catch (Exception x) {
- LOGGER.log(Level.FINE, null, x);
- return false;
- }
+ return provider != null;
}
- private /*WebSocketServletFactory*/Object factory;
-
- private synchronized Object init() throws Exception {
- if (factory == null) {
- staticInit();
- Class> webSocketPolicyClass = cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketPolicy");
- factory = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketServletFactory$Loader")
- .getMethod("load", ServletContext.class, webSocketPolicyClass)
- .invoke(
- null,
- Stapler.getCurrent().getServletContext(),
- webSocketPolicyClass.getMethod("newServerPolicy").invoke(null));
- webSocketServletFactoryClass.getMethod("start").invoke(factory);
- Class> webSocketCreatorClass = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketCreator");
- webSocketServletFactoryClass.getMethod("setCreator", webSocketCreatorClass).invoke(factory, Proxy.newProxyInstance(cl, new Class>[] {webSocketCreatorClass}, this::createWebSocket));
- }
- return factory;
- }
-
- private Object createWebSocket(Object proxy, Method method, Object[] args) throws Exception {
- Object servletUpgradeRequest = args[0];
- WebSocketSession session = (WebSocketSession) servletUpgradeRequest.getClass().getMethod("getServletAttribute", String.class).invoke(servletUpgradeRequest, ATTR_SESSION);
- return Proxy.newProxyInstance(cl, new Class>[] {cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketListener")}, session::onWebSocketSomething);
- }
+ private WebSockets() {}
}
diff --git a/pom.xml b/pom.xml
index 4679531fe3d9..ee1dafc4a6de 100644
--- a/pom.xml
+++ b/pom.xml
@@ -51,6 +51,8 @@ THE SOFTWARE.
bom
+ websocket/spi
+ websocket/jetty9
core
war
test
@@ -94,7 +96,7 @@ THE SOFTWARE.
Max
Medium
- ${project.basedir}/../src/spotbugs/spotbugs-excludes.xml
+ ${maven.multiModuleProjectDirectory}/src/spotbugs/spotbugs-excludes.xml
1.27
1.23
diff --git a/src/spotbugs/spotbugs-excludes.xml b/src/spotbugs/spotbugs-excludes.xml
index 96dd3781fb30..50f646752f88 100644
--- a/src/spotbugs/spotbugs-excludes.xml
+++ b/src/spotbugs/spotbugs-excludes.xml
@@ -658,6 +658,8 @@
+
+
diff --git a/test/src/test/java/hudson/slaves/JNLPLauncherRealTest.java b/test/src/test/java/hudson/slaves/JNLPLauncherRealTest.java
index cbaf383b7db2..ac31919de8aa 100644
--- a/test/src/test/java/hudson/slaves/JNLPLauncherRealTest.java
+++ b/test/src/test/java/hudson/slaves/JNLPLauncherRealTest.java
@@ -29,11 +29,21 @@
import hudson.ExtensionList;
import hudson.PluginWrapper;
+import hudson.Proc;
import hudson.model.FreeStyleBuild;
import hudson.model.FreeStyleProject;
import hudson.model.Slave;
import hudson.util.FormValidation;
+import io.jenkins.lib.support_log_formatter.SupportLogFormatter;
+import java.io.File;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import jenkins.agents.WebSocketAgentsTest;
import jenkins.slaves.JnlpSlaveAgentProtocol4;
+import org.apache.commons.io.FileUtils;
+import org.apache.tools.ant.util.JavaEnvUtils;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.Description;
@@ -75,4 +85,48 @@ private static void _smokes(JenkinsRule r) throws Throwable {
}
+ /**
+ * Simplified version of {@link WebSocketAgentsTest#smokes} just checking Jetty/Winstone.
+ */
+ @Issue("JENKINS-68933")
+ @Test public void webSocket() throws Throwable {
+ rr.then(JNLPLauncherRealTest::_webSocket);
+ }
+
+ private static void _webSocket(JenkinsRule r) throws Throwable {
+ // TODO RealJenkinsRule does not yet support LoggerRule
+ Handler handler = new ConsoleHandler();
+ handler.setFormatter(new SupportLogFormatter());
+ handler.setLevel(Level.FINE);
+ Logger logger = Logger.getLogger("jenkins.websocket");
+ logger.setLevel(Level.FINE);
+ logger.addHandler(handler);
+ assertThat(ExtensionList.lookupSingleton(JNLPLauncher.DescriptorImpl.class).doCheckWebSocket(true, null).kind, is(FormValidation.Kind.OK));
+ // TODO InboundAgentRule does not yet support WebSocket
+ JNLPLauncher launcher = new JNLPLauncher(true);
+ launcher.setWebSocket(true);
+ DumbSlave s = new DumbSlave("remote", new File(r.jenkins.root, "agent").getAbsolutePath(), launcher);
+ r.jenkins.addNode(s);
+ String secret = ((SlaveComputer) s.toComputer()).getJnlpMac();
+ File slaveJar = new File(r.jenkins.root, "agent.jar");
+ FileUtils.copyURLToFile(new Slave.JnlpJar("agent.jar").getURL(), slaveJar);
+ Proc proc = r.createLocalLauncher().launch().cmds(
+ JavaEnvUtils.getJreExecutable("java"), "-jar", slaveJar.getAbsolutePath(),
+ "-jnlpUrl", r.getURL() + "computer/remote/jenkins-agent.jnlp",
+ "-secret", secret
+ ).stdout(System.out).start();
+ try {
+ FreeStyleProject p = r.createFreeStyleProject();
+ p.setAssignedNode(s);
+ r.buildAndAssertSuccess(p);
+ assertThat(s.toComputer().getSystemProperties().get("java.class.path"), is(slaveJar.getAbsolutePath()));
+ } finally {
+ proc.kill();
+ while (r.jenkins.getComputer("remote").isOnline()) {
+ System.err.println("waiting for computer to go offline");
+ Thread.sleep(250);
+ }
+ }
+ }
+
}
diff --git a/war/pom.xml b/war/pom.xml
index 4031c7977a02..1db05dc7b875 100644
--- a/war/pom.xml
+++ b/war/pom.xml
@@ -96,6 +96,11 @@ THE SOFTWARE.
+
+ org.jenkins-ci.main
+ websocket-jetty9
+ ${project.version}
+
+
+ 4.0.0
+
+
+ org.jenkins-ci.main
+ jenkins-parent
+ ${revision}${changelist}
+ ../..
+
+
+ websocket-jetty9
+ Jetty 9 implementation for WebSocket
+ An implementation of the WebSocket handler that works with Jetty 9.
+
+
+
+
+ org.jenkins-ci.main
+ jenkins-bom
+ ${project.version}
+ pom
+ import
+
+
+
+
+
+
+ org.jenkins-ci
+ winstone
+ 5.25
+ true
+
+
+ org.jenkins-ci.main
+ websocket-spi
+ ${project.version}
+
+
+ org.kohsuke
+ access-modifier-annotation
+
+
+ org.kohsuke.metainf-services
+ metainf-services
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+
+ true
+
+
+
+
+
diff --git a/websocket/jetty9/src/main/java/jenkins/websocket/Jetty9Provider.java b/websocket/jetty9/src/main/java/jenkins/websocket/Jetty9Provider.java
new file mode 100644
index 000000000000..ed5156980f7b
--- /dev/null
+++ b/websocket/jetty9/src/main/java/jenkins/websocket/Jetty9Provider.java
@@ -0,0 +1,149 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2022 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package jenkins.websocket;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.Future;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.WebSocketListener;
+import org.eclipse.jetty.websocket.api.WebSocketPolicy;
+import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
+import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse;
+import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+import org.kohsuke.MetaInfServices;
+import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.NoExternalUse;
+
+@Restricted(NoExternalUse.class)
+@MetaInfServices(Provider.class)
+public class Jetty9Provider implements Provider {
+
+ private static final String ATTR_LISTENER = Jetty9Provider.class.getName() + ".listener";
+
+ // TODO does not seem possible to use HttpServletRequest.get/setAttribute for this
+ private static final Map sessions = new WeakHashMap<>();
+
+ private WebSocketServletFactory factory;
+
+ public Jetty9Provider() {
+ WebSocketServletFactory.class.hashCode();
+ }
+
+ private synchronized void init(HttpServletRequest req) throws Exception {
+ if (factory == null) {
+ factory = WebSocketServletFactory.Loader.load(req.getServletContext(), WebSocketPolicy.newServerPolicy());
+ factory.start();
+ factory.setCreator(Jetty9Provider::createWebSocket);
+ }
+ }
+
+ @Override
+ public Handler handle(HttpServletRequest req, HttpServletResponse rsp, Listener listener) throws Exception {
+ init(req);
+ req.setAttribute(ATTR_LISTENER, listener);
+ if (!factory.isUpgradeRequest(req, rsp)) {
+ rsp.sendError(HttpServletResponse.SC_BAD_REQUEST, "only WS connections accepted here");
+ return null;
+ }
+ if (!factory.acceptWebSocket(req, rsp)) {
+ rsp.sendError(HttpServletResponse.SC_BAD_REQUEST, "did not manage to upgrade");
+ return null;
+ }
+ return new Handler() {
+ @Override
+ public Future sendBinary(ByteBuffer data) throws IOException {
+ return session().getRemote().sendBytesByFuture(data);
+ }
+
+ @Override
+ public void sendBinary(ByteBuffer partialByte, boolean isLast) throws IOException {
+ session().getRemote().sendPartialBytes(partialByte, isLast);
+ }
+
+ @Override
+ public Future sendText(String text) throws IOException {
+ return session().getRemote().sendStringByFuture(text);
+ }
+
+ @Override
+ public void sendPing(ByteBuffer applicationData) throws IOException {
+ session().getRemote().sendPing(applicationData);
+ }
+
+ @Override
+ public void close() throws IOException {
+ session().close();
+ }
+
+ private Session session() {
+ Session session = sessions.get(listener);
+ if (session == null) {
+ throw new IllegalStateException("missing session");
+ }
+ return session;
+ }
+ };
+ }
+
+ private static Object createWebSocket(ServletUpgradeRequest req, ServletUpgradeResponse resp) {
+ Listener listener = (Listener) req.getHttpServletRequest().getAttribute(ATTR_LISTENER);
+ if (listener == null) {
+ throw new IllegalStateException("missing listener attribute");
+ }
+ return new WebSocketListener() {
+ @Override
+ public void onWebSocketBinary(byte[] payload, int offset, int length) {
+ listener.onWebSocketBinary(payload, offset, length);
+ }
+
+ @Override
+ public void onWebSocketText(String message) {
+ listener.onWebSocketText(message);
+ }
+
+ @Override
+ public void onWebSocketClose(int statusCode, String reason) {
+ listener.onWebSocketClose(statusCode, reason);
+ }
+
+ @Override
+ public void onWebSocketConnect(Session session) {
+ sessions.put(listener, session);
+ listener.onWebSocketConnect();
+ }
+
+ @Override
+ public void onWebSocketError(Throwable cause) {
+ listener.onWebSocketError(cause);
+ }
+ };
+ }
+
+}
diff --git a/websocket/spi/pom.xml b/websocket/spi/pom.xml
new file mode 100644
index 000000000000..90ec18754ae9
--- /dev/null
+++ b/websocket/spi/pom.xml
@@ -0,0 +1,70 @@
+
+
+
+ 4.0.0
+
+
+ org.jenkins-ci.main
+ jenkins-parent
+ ${revision}${changelist}
+ ../..
+
+
+ websocket-spi
+ Internal SPI for WebSocket
+ An abstraction of how Jenkins can serve a WebSocket connection from its servlet container.
+
+
+
+
+ org.jenkins-ci.main
+ jenkins-bom
+ ${project.version}
+ pom
+ import
+
+
+
+
+
+
+ javax.servlet
+ javax.servlet-api
+ 3.1.0
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+
+ true
+
+
+
+
+
diff --git a/websocket/spi/src/main/java/jenkins/websocket/Provider.java b/websocket/spi/src/main/java/jenkins/websocket/Provider.java
new file mode 100644
index 000000000000..caef2559dfb3
--- /dev/null
+++ b/websocket/spi/src/main/java/jenkins/websocket/Provider.java
@@ -0,0 +1,76 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2022 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package jenkins.websocket;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.Future;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Defines a way for Jenkins core to serve WebSocket connections.
+ * This permits core to use one or more implementations supplied by the servlet container,
+ * based on static compilation while not having a hard dependency on any one.
+ * ({@code javax.websocket.*} APIs are not suited to usage from Stapler.)
+ * The constructor should try to link against everything necessary so any errors are thrown up front.
+ */
+interface Provider {
+
+ /**
+ * Handle a WebSocket server request.
+ * @return a handler, unless an error code was already set
+ */
+ Handler handle(HttpServletRequest req, HttpServletResponse rsp, Listener listener) throws Exception;
+
+ interface Listener {
+
+ void onWebSocketConnect();
+
+ void onWebSocketClose(int statusCode, String reason);
+
+ void onWebSocketError(Throwable cause);
+
+ void onWebSocketBinary(byte[] payload, int offset, int length);
+
+ void onWebSocketText(String message);
+
+ }
+
+ interface Handler {
+
+ Future sendBinary(ByteBuffer data) throws IOException;
+
+ void sendBinary(ByteBuffer partialByte, boolean isLast) throws IOException;
+
+ Future sendText(String text) throws IOException;
+
+ void sendPing(ByteBuffer applicationData) throws IOException;
+
+ void close() throws IOException;
+
+ }
+
+}