Configuring WebSocket Clients

 

Author: Eike Stepper

The core WS bundle registers a JDK WebSocket message-connection provider named jdk. Connector factories select that provider by default. Applications can register additional provider factories to bind a client, TLS context, proxy policy, authentication policy, or opening-handshake timeout to a distinct provider name.

Table of Contents

1 Default Provider and Connector Descriptions
2 Registering Configured Providers
3 Cookies, Authentication, and Proxy Tunnels
4 Reconstructing Connectors Safely

1  Default Provider and Connector Descriptions

Create connectors with WSUtil for WS and WSSUtil for WSS. A secure connection needs both the WSS connector factory and a wss service URI. The named acceptor is encoded in the route path. With no provider token, the factory selects jdk.

DefaultWSAndWSS.java      
IManagedContainer container = ContainerUtil.createInitializedContainer();
IWSConnector ws = null;
IWSConnector wss = null;

try
{
  ws = WSUtil.getConnector(container, URI.create("ws://gateway.example.net/net4j"), "public");
  wss = WSSUtil.getConnector(container, URI.create("wss://gateway.example.net/net4j"), "secure");

  ws.connect(5_000);
  wss.connect(5_000);
  // Use the connected transports while the connectors remain active.
}
finally
{
  try
  {
    if (ws != null)
    {
      ws.close();
    }
  }
  finally
  {
    try
    {
      if (wss != null)
      {
        wss.close();
      }
    }
    finally
    {
      LifecycleUtil.deactivate(container);
    }
  }
}

Connector descriptions can include provider:<type> to select a registered message-connection factory and cookie:<name>=<value> for a handshake cookie. Use the utility description methods instead of manually constructing the route or delimiter escaping. Descriptions are configuration data; protect any saved description that contains a session cookie.

2  Registering Configured Providers

Use JDKWSUtil.createMessageConnectionFactory(type, client, timeout) to configure a provider with a caller-owned HttpClient. Register the factory in the same managed container that creates the connector, and select the registered type with the provider token. Different provider names allow the same application to use distinct trust stores or proxy routes without changing global JDK settings.

The following example uses an explicit proxy selector and handshake cookie. It uses the default JDK trust context as a placeholder; use an application-configured trust context for private certificate authorities.

ConfiguredProvider.java      
IManagedContainer container = ContainerUtil.createInitializedContainer();
IWSConnector connector = null;
HttpClient httpClient = null;

try
{
  SSLContext tls = SSLContext.getDefault();
  httpClient = HttpClient.newBuilder().sslContext(tls).proxy(ProxySelector.of(new InetSocketAddress("proxy.example.net", 8080)))
      .connectTimeout(Duration.ofSeconds(8)).build();
  IFactory provider = JDKWSUtil.createMessageConnectionFactory("corporate", httpClient, Duration.ofSeconds(12));
  container.registerFactory(provider);

  String description = WSSUtil.getConnectorDescription(URI.create("wss://gateway.example.net/net4j"), "secure", "provider:corporate",
      "cookie:tenant=example");
  connector = WSSUtil.getConnector(container, description);
  connector.connect(15_000);
  // Use the connector while its container and caller-owned HttpClient remain alive.
}
catch (Exception ex)
{
  throw new IllegalStateException("Unable to configure or connect the WSS client", ex);
}
finally
{
  try
  {
    if (connector != null)
    {
      connector.close();
    }
  }
  finally
  {
    LifecycleUtil.deactivate(container);
  }

  // On Java 21+, close httpClient here after all connectors that use it have stopped.
}

The HTTP client's connectTimeout limits the connection establishment stage. The provider's webSocketConnectTimeout limits the subsequent opening handshake. A connector's connect(5000) bounds how long the calling thread waits for the Net4j connector to become connected; these are separate limits. The built-in provider defaults both network stages to ten seconds. Use JDKWSOptions.builder() with the HttpClient or SSLContext factory overload to set the opening-handshake timeout, heartbeat interval, and maximum message size. Defaults are ten seconds, twenty seconds, and Short.MAX_VALUE bytes respectively. The message limit includes the Net4j frame header and must remain within the short-sized Net4j framing limit. Options belong to one provider factory and are applied to all of its newly created connections; separate provider types can use different options concurrently. The heartbeat is an unsolicited WebSocket Pong sent periodically while the connection is open. Set its interval below the server's WebSocket idle timeout to keep an otherwise quiet connection alive. It is cancelled when graceful closure or termination begins.

3  Cookies, Authentication, and Proxy Tunnels

Handshake cookies are sent to the WebSocket endpoint during the HTTP upgrade. Treat them like credentials: limit their scope and lifetime, use WSS, protect connector descriptions that contain them, and do not log full descriptions.

Configure HTTP origin and proxy authentication on the JDK Authenticator. The requestor type distinguishes a server challenge from a proxy challenge; do not send origin credentials to a proxy or vice versa. The application should obtain usernames and passwords from its credential store rather than embedding them in code. An authenticator can serve several destinations, so restrict each credential to the expected requestor host and port and return null for every unknown endpoint.

For HTTP CONNECT, the JDK normally disables Basic authentication for tunneling. The system property jdk.http.auth.tunneling.disabledSchemes controls disabled schemes. Setting it to an empty value can enable Basic CONNECT authentication, but it changes behavior JVM-wide and may expose credentials to an untrusted proxy. Review that boundary carefully and prefer a supported stronger proxy scheme where possible.

ChallengeAuthentication.java      
IManagedContainer container = ContainerUtil.createInitializedContainer();
HttpClient httpClient = null;
IWSConnector connector = null;

try
{
  String originUser = System.getenv("NET4J_ORIGIN_USER");
  String originPassword = System.getenv("NET4J_ORIGIN_PASSWORD");
  String proxyUser = System.getenv("NET4J_PROXY_USER");
  String proxyPassword = System.getenv("NET4J_PROXY_PASSWORD");
  if (originUser == null || originPassword == null || proxyUser == null || proxyPassword == null)
  {
    throw new IllegalStateException("Provide origin and proxy credentials through the deployment secret store");
  }

  Authenticator authenticator = new Authenticator()
  {
    @Override
    protected PasswordAuthentication getPasswordAuthentication()
    {
      String requestingHost = getRequestingHost();
      int requestingPort = getRequestingPort();

      if (getRequestorType() == Authenticator.RequestorType.PROXY && "proxy.example.net".equalsIgnoreCase(requestingHost) && requestingPort == 8080)
      {
        return new PasswordAuthentication(proxyUser, proxyPassword.toCharArray());
      }

      if (getRequestorType() == Authenticator.RequestorType.SERVER && "gateway.example.net".equalsIgnoreCase(requestingHost) && requestingPort == 443)
      {
        return new PasswordAuthentication(originUser, originPassword.toCharArray());
      }

      // Never supply credentials to an unknown host, port, or requestor type.
      return null;
    }
  };

  httpClient = HttpClient.newBuilder().proxy(ProxySelector.of(new InetSocketAddress("proxy.example.net", 8080))).authenticator(authenticator)
      .connectTimeout(Duration.ofSeconds(8)).build();
  IFactory provider = JDKWSUtil.createMessageConnectionFactory("authenticated", httpClient, Duration.ofSeconds(10));
  container.registerFactory(provider);
  connector = WSSUtil.getConnector(container, URI.create("wss://gateway.example.net/net4j"), "secure", "provider:authenticated");
  connector.connect(15_000);
  // Use the connector while the provider, container, and caller-owned client remain alive.
}
catch (Exception ex)
{
  throw new IllegalStateException("Unable to configure authenticated WSS", ex);
}
finally
{
  try
  {
    if (connector != null)
    {
      connector.close();
    }
  }
  finally
  {
    LifecycleUtil.deactivate(container);
    // On Java 21+, close httpClient here after all connectors that use it have stopped.
  }
}

4  Reconstructing Connectors Safely

IConnector.getURL() reports the endpoint URL, not the complete WS/WSS factory description. It omits the selected provider and handshake cookies. Rebuilding from that URL can silently use jdk and lose a custom TLS trust policy. Retain the original description or ask the registered connector factory for IFactory.getDescriptionFor(connector). Select the WS or WSS factory based on the connector's scheme.

The example retains the complete string in application configuration. It does not claim to test a restoration by looking up the product again in the same container. Protect the stored value because it may contain cookie values.

CompleteWSSDescription.java      
IManagedContainer container = ContainerUtil.createInitializedContainer();

try
{
  HttpClient httpClient = HttpClient.newBuilder().build();
  IFactory provider = JDKWSUtil.createMessageConnectionFactory("corporate", httpClient, Duration.ofSeconds(10));
  container.registerFactory(provider);

  IWSConnector connector = WSSUtil.getConnector(container, URI.create("wss://gateway.example.net/net4j"), "secure", "provider:corporate",
      "cookie:tenant=example");
  IFactory connectorFactory = container.getFactory(ConnectorFactory.PRODUCT_GROUP, WSSUtil.FACTORY_TYPE);
  String completeDescription = connectorFactory.getDescriptionFor(connector);

  Properties protectedConfiguration = new Properties();
  protectedConfiguration.setProperty("connector.wss.secure", completeDescription);
  // Store protectedConfiguration with the application's credential-aware configuration service.
  // On Java 21+, close httpClient after all connectors using it have stopped.
}
finally
{
  LifecycleUtil.deactivate(container);
}