Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: Tests

on:
push:
pull_request:

permissions:
contents: read

jobs:
boxlang:
name: BoxLang bleeding edge / Java 21
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Java 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"

- name: Set up CommandBox
uses: Ortus-Solutions/setup-commandbox@v2.0.1
with:
version: 6.3.1

- name: Install dependencies
run: box install

- name: Show runtime versions
run: |
java -version
box version

- name: Start BoxLang
run: box server start server-boxlang-be.json

- name: Run tests with BoxLang
run: >-
curl --fail-with-body --silent --show-error
"http://127.0.0.1:8600/tests/runner.cfm?reporter=json&directory=tests.specs"

- name: Verify test result
shell: bash
run: |
if ! grep -qx 'test.passed=true' tests/results/TEST.properties; then
echo '::error::TestBox reported failures or errors.'
exit 1
fi

- name: Stop BoxLang
if: always()
run: box server stop server-boxlang-be.json

lucee:
name: Lucee 5.4 / Java 11
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Java 11
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "11"

- name: Set up CommandBox
uses: Ortus-Solutions/setup-commandbox@v2.0.1
with:
version: 6.3.1

- name: Install dependencies
run: box install

- name: Show runtime versions
run: |
java -version
box version

- name: Start Lucee
run: box server start server-lucee5.json

- name: Run tests with Lucee
run: >-
curl --fail-with-body --silent --show-error
"http://127.0.0.1:8599/tests/runner.cfm?reporter=json&directory=tests.specs"

- name: Verify test result
shell: bash
run: |
if ! grep -qx 'test.passed=true' tests/results/TEST.properties; then
echo '::error::TestBox reported failures or errors.'
exit 1
fi

- name: Stop Lucee
if: always()
run: box server stop server-lucee5.json
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/models/cluster/cbproxies
/testbox/
/tests/results/
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ If `debugMode` is set to true, the entire config will be reloaded every request

The `heartBeatMS` setting controls the number of ms between heartbeat "pings" from every client websocket connection. Set to `0` to disable this. Heartbeats are useful so clients can reconnect right away if the connection is interrupted, but can create a lot of network chatter if you have many connections.

#### Cluster lifecycle

When clustering is enabled, SocketBox uses one shared JDK HTTP client per cluster manager for all outgoing peer WebSockets. The `cluster.peerConnectionTimeoutSeconds` setting applies to the actual WebSocket opening handshake. Failed handshakes are cancelled and retried using the cluster manager's adaptive delay; repeated failures do not reset the delay to its fastest interval.

Call the inherited `shutdown()` method from your application's shutdown callback. This removes the server from the shared peer cache, cancels pending handshakes, and closes its peer WebSockets. Java 21 and newer explicitly shut down the shared HTTP client; on Java 11 through 20, reusing one client keeps the selector-thread count bounded and releasing it after the WebSockets close lets its selector be reclaimed.

Cache-backed discovery stores a shared list of peer names plus a last-check-in entry for each peer. Expired peers are removed from the shared list as one verified batch so concurrent cleanup cannot cause in-process removal updates to overwrite one another. Cache providers with atomic collection and expiry operations may still provide stronger guarantees than SocketBox's portable `get()`, `set()`, and `clear()` contract.

#### Exchanges

You can configure exchanges as shown above. The direct exchange will always be configured by default and you only need to specify it if you want to pass custom bindings to it. Exchanges receive the incoming messages and decide what destinations, if any, to route them to. An exchange could route a message to 0 destinations or 1000 destinations. And each destination could have 0 subscribers or 1000 subscribers. The exchange abstracts all that away from you. Publishers simply send messages to an exchange wihtout caring who is subscribed, and subscribers simply receive messages from their subscriptions without caring who sent it.
Expand Down Expand Up @@ -297,4 +305,4 @@ You can create a simple STOMP connection like so:
client.activate();

</script>
```
```
9 changes: 7 additions & 2 deletions box.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"dependencies":{
"cbproxies":"^1.7.0+8"
},
"devDependencies":{
"testbox":"^7.0.0+19"
},
"description":"WebSocket Listener library to be used with CommandBox Websocket and BoxLang WebSocket server.",
"documentation":"https://forgebox.io/view/socketbox",
"homepage":"https://forgebox.io/view/socketbox",
Expand All @@ -12,7 +15,8 @@
"/tests/"
],
"installPaths":{
"cbproxies":"models/cluster/cbproxies/"
"cbproxies":"models/cluster/cbproxies/",
"testbox":"testbox/"
},
"license":[
{
Expand All @@ -29,7 +33,8 @@
},
"scripts":{
"onRelease":"publish",
"postPublish":"!git push --follow-tags"
"postPublish":"!git push --follow-tags",
"test":"!testbox/run"
},
"shortDescription":"WebSocket Listener library",
"slug":"socketbox",
Expand Down
44 changes: 35 additions & 9 deletions models/WebSocketCore.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ component {
// should use the HTTP port that the server is listening on. Note, if this server is behind a proxy or load balancer, you need to provide
// an INTERNAL address and/or port that the other servers in the cluster can connect to directly which doesn't flow through the proxy.
// Defaults to the server's hostname and the HTTP port in use.
"name" : "ws://#createObject("java", "java.net.InetAddress").getLocalHost().getHostName()#:#cgi.server_port#/ws",
"name" : getDefaultClusterName(),
// Hard-coded list of cluster peers to connect to. These are always used regardless of external cache.
"peers" : [],
// A class or object with MINIMUM get(), set(), and clear() methods to use as a cache provider.
Expand All @@ -42,6 +42,21 @@ component {
"defaultRPCTimeoutSeconds" : 15
}
};

/**
* Build the default cluster address from the current web request.
* Non-web contexts use port 80 until an explicit configured name replaces
* this construction-time default.
*/
private function getDefaultClusterName() {
var serverPort = 80;
try {
serverPort = cgi.server_port;
} catch( any ignored ) {
// Non-web contexts have no CGI scope.
}
return "ws://#createObject("java", "java.net.InetAddress").getLocalHost().getHostName()#:#serverPort#/ws";
}

/**
* Front controller for all WebSocket incoming messages
Expand Down Expand Up @@ -155,9 +170,26 @@ component {
}
application.SocketBoxConfig = local.config;

// A reconfiguration must explicitly stop the previous manager before
// replacing it. Its peer connections belong to its shared HTTP client
// and cannot be transferred safely to a new manager.
var oldManager = '';
if(
application.keyExists( 'socketBoxClusterManagement' ) &&
application.socketBoxClusterManagement.keyExists( 'clusterManager' )
) {
oldManager = application.socketBoxClusterManagement.clusterManager;
}
if( !isSimpleValue( oldManager ) ) {
try {
oldManager.shutdown();
} finally {
application.delete( 'socketBoxClusterManagement' );
}
}

// Setup the cluster if enabled
if( local.config.cluster.enable ) {
var oldManager = application.socketBoxClusterManagement.clusterManager ?: '';
application.socketBoxClusterManagement = {
"clusterManager" : new cluster.ClusterManager( this, config ),
// Incoming connections from regular clients
Expand All @@ -168,12 +200,6 @@ component {
// cluster name is configured correctly.
"selfChannels" : {}
};

// Don't let existing connections leak
if( !isSimpleValue( oldManager ) ) {
application.socketBoxClusterManagement.clusterManager.setPeerConnections( oldManager.getPeerConnections() );
}

// Start the cluster manager once the config is fully set up
application.socketBoxClusterManagement.clusterManager.start()
}
Expand Down Expand Up @@ -741,4 +767,4 @@ component {
return target;
}

}
}
Loading