Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43a5f0f
Add dormant-by-default info() API with lazily loaded ToolSocketInfo h…
claude Jul 11, 2026
f00a3a6
info(): report every 5s via callback; first key: latency from built-i…
claude Jul 11, 2026
b128270
Make info() API server side only: moved from ToolSocket to IncomingTo…
claude Jul 11, 2026
9e1f96f
info(): transport measured via TCP socket counters (zero per-message …
claude Jul 11, 2026
0d68fb1
info(): dual latency keys — networkLatency (protocol PING/PONG frames…
claude Jul 11, 2026
2e31096
info(): networkQuality key — derived flow/score/rating/issues for non…
claude Jul 11, 2026
dffe4d1
info(): networkQuality issues carry whatIsHappening/likelyCause/tellY…
claude Jul 11, 2026
c57fd80
info(): refined quality scoring — per-dimension sub-scores, anchor cu…
claude Jul 11, 2026
31b1a05
info(): on-demand throughput probe via info(true, cb, {probe: true});…
claude Jul 11, 2026
a6d8b45
probe: contention-aware — concurrent traffic accounting, latency unde…
claude Jul 11, 2026
5d56a26
info(): NB compatibility — backpressure sampling includes NB schedule…
claude Jul 11, 2026
3014c0a
info(): connection naming via options.name and persistent issue histo…
claude Jul 11, 2026
d614169
server.stagedProbe(): individual + growing simultaneous probes to sep…
claude Jul 11, 2026
4928756
Client-side remote info API: subscribe/stop server-wide info stream a…
claude Jul 11, 2026
0d98ecb
Production pass: fix 1005 clean-close misclassification, stagedProbe …
claude Jul 11, 2026
ea73380
Client self-naming for connection info: infoName() + info/name meta r…
vheun Jul 13, 2026
8371941
stagedProbe options.names: probe only selected named connections
vheun Jul 13, 2026
6c12b10
Per-connection info id + id-addressed probes + parallel-socket name i…
vheun Jul 13, 2026
4835ef1
info(): probeRamp option — skip intermediate staged-probe stages
vheun Jul 13, 2026
170210d
lint: declare jest globals in the info test suite
vheun Jul 14, 2026
b92d0cf
test: poll-based waits in the info suite — flake-proof on slow CI
vheun Jul 14, 2026
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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,58 @@ clearly named getters and setters for ease of use. See `src/ToolSocketMessage.js
f/frameCount: (number of binary buffers that will be sent following this message)
}
```

## Connection Info API

Server-side diagnostics for realtime connection quality. Fully dormant until enabled:
the info module is only loaded on first use and adds zero per-message overhead —
measurement rides on TCP byte counters, WebSocket protocol ping/pong frames, and the
existing keepalive, all sampled by a single 1 Hz ticker.

### Per connection (server side)
```javascript
webSocketServer.on('connection', (socket) => {
socket.info(true, (report) => {
// Every 5 seconds:
// report.data.networkLatency - protocol-level ping RTT (pure network path)
// report.data.appLatency - application-level RTT (network + client pressure)
// report.data.transport - exact wire bytes: average/peak per second, totals
// report.data.networkQuality - score, rating, flow (realtime/buffered/stalled),
// trend, per-dimension sub-scores, issue flags
// report.data.history - issue counts/episodes since info was enabled
// report.data.probe - latest on-demand throughput probe result
}, {name: 'alice'}); // optional connection name, included as report.data.name

socket.info(true, undefined, {probe: true}); // one-shot up/downstream capacity probe
socket.info(false); // full teardown, back to dormant
});
```
When the connection closes, one final report is pushed (`flow: 'ended'`, including
the close code — an abnormal close without a handshake is the typical signature of
proxies or zero-trust gateways cutting the socket).

### Across all connections (server side)
```javascript
webSocketServer.stagedProbe((result) => {
// result.individual - every client probed alone, sequentially (per-client limits)
// result.stages - 2, 4, 8, ... clients probed simultaneously
// result.networkLimit - all-at-once totals (shared-medium capacity)
// result.sharedBottleneck - ratio of individual sum to network limit; detected: true
// means clients limit each other (e.g. shared WiFi)
}, {sizeBytes: 256 * 1024});
```

### Remote (client side)
```javascript
clientSocket.info(true, (bundle) => {
// Every 5 seconds, pushed by the server:
// bundle.reports - info reports of ALL server connections
// bundle.recentlyClosed - final reports of recently closed connections
// bundle.stagedProbe - latest staged probe result
});
clientSocket.info(true, undefined, {probe: true}); // trigger a server-wide staged probe
clientSocket.info(false); // stop the stream
```
The stream also ends automatically when the subscribing connection closes. There is
no built-in authorization: gate this at the application level if info reports should
not be visible to every client.
84 changes: 84 additions & 0 deletions src/IncomingToolSocket.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const ToolSocket = require("./ToolSocket");
const { generateUniqueId } = require("./utilities.js");

class IncomingToolSocket extends ToolSocket {
/**
Expand All @@ -12,9 +13,92 @@ class IncomingToolSocket extends ToolSocket {
this.networkId = 'toolbox'; // Or 'io'?
this.origin = server.origin;
this.server = server;

/**
* Lazy-initialized info handler (see info()). Stays null while info mode is off,
* in which case the info module is never loaded and no info code runs at all.
* @type {?Object}
*/
this.infoHandler = null;

/**
* Stable identifier for this connection, included in every info report as
* data.id — lets UIs and the staged probe address a specific connection
* (named or not) for its whole lifetime.
* @type {string}
*/
this.infoId = generateUniqueId(8);

/**
* Connection name announced by the remote end via the meta route info/name
* (client-side infoName() API). Lives on the socket rather than the handler
* so it survives info enable/disable cycles; stamped into the handler
* whenever info is (re-)enabled.
* @type {?string}
*/
this.announcedInfoName = null;

this.configureSocket();
}

/**
* Enables or disables info updates about this server-side WebSocket connection.
* This API is server side only and intentionally not available on client sockets.
*
* When enabled is false (the default), the entire info subsystem is dormant:
* the info module is not loaded, no listeners are registered, and the send/receive
* hot paths carry zero extra processing overhead.
*
* When enabled is true, the info module is lazily loaded on first use and begins
* delivering info reports to the provided callback every 5 seconds. Calling
* info(true, cb) again simply replaces the callback. Calling info(false) (or
* info()) tears the info subsystem down completely, returning the socket to its
* dormant state.
*
* @param {boolean} [enabled=false] - Whether info updates should be active
* @param {?function} [infoCallback] - Called with info report objects while enabled.
* Omit (undefined) to keep the current callback,
* e.g. when only triggering a probe.
* Report content is defined in ToolSocketInfo.js.
* @param {?Object} [options] - Additional actions:
* @param {string} [options.name] - Assigns a name to this connection (e.g. the
* user name the server identified it with);
* included in every report as data.name
* @param {boolean} [options.probe] - If true, runs a one-shot throughput probe
* (max upstream/downstream measurement). The
* result is included in every report's
* data.probe until the next probe replaces it.
* Intended to be triggered by a UI button.
* @param {number} [options.probeSizeBytes] - Probe payload size per direction
* (default 256 KB, capped at 4 MB)
*/
info(enabled = false, infoCallback, options) {
if (enabled) {
if (!this.infoHandler) {
// Lazy require: this module is only ever loaded once info mode is activated
const ToolSocketInfo = require('./ToolSocketInfo.js');
this.infoHandler = new ToolSocketInfo(this);
}
if (infoCallback !== undefined) {
this.infoHandler.setCallback(infoCallback);
}
this.infoHandler.start();
if (options && typeof options.name === 'string') {
this.infoHandler.setName(options.name);
} else if (this.announcedInfoName) {
// name announced by the remote end (infoName()); re-applied on every
// enable, so it survives the auto-enable/disable subscriber cycles
this.infoHandler.setName(this.announcedInfoName);
}
if (options && options.probe) {
this.infoHandler.startProbe(options.probeSizeBytes);
}
} else if (this.infoHandler) {
this.infoHandler.stop();
this.infoHandler = null;
}
}

/**
* Requests the source to create another ToolSocket connection for parallel data transfer.
* @return {Promise<ToolSocket>} - The parallel socket we just created.
Expand Down
186 changes: 183 additions & 3 deletions src/ToolSocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const ToolSocketMessage = require('./ToolSocketMessage.js');
const ToolSocketResponse = require('./ToolSocketResponse.js');
const MessageBundle = require('./MessageBundle.js');

const { generateUniqueId, addSearchParams, isBrowser, WebSocketWrapper } = require('./utilities.js');
const { generateUniqueId, addSearchParams, isBrowser, WebSocketWrapper, makeProbePayload } = require('./utilities.js');
const { VALID_METHODS, MAX_MESSAGE_SIZE } = require('./constants.js');
const { URL_SCHEMA, MESSAGE_BUNDLE_SCHEMA } = require('./schemas.js');

Expand All @@ -27,6 +27,12 @@ class ToolSocket {

this.eventCallbacks = {}; // For events
this.responseCallbacks = {}; // For handling direct responses to sent messages

// Client-side remote info subscription state (see info())
/** @type {?function} */
this.remoteInfoCallback = null;
this.remoteInfoSubscribed = false;
this.remoteInfoReattachArmed = false;
/** @type {?BinaryBuffer} */
this.binaryBuffer = null;

Expand Down Expand Up @@ -128,6 +134,22 @@ class ToolSocket {
this.eventCallbacks[eventType].forEach(callback => callback(...args));
}

/**
* Removes a previously added event listener
* @param {string} eventType - The event type the listener was added for
* @param {function} callback - The exact callback that was passed to addEventListener
*/
removeEventListener(eventType, callback) {
if (!this.eventCallbacks[eventType]) {
return;
}
this.eventCallbacks[eventType] = this.eventCallbacks[eventType].filter(cb => cb !== callback);
if (this.eventCallbacks[eventType].length === 0) {
// Restore the "no listeners" fast path in triggerEvent
delete this.eventCallbacks[eventType];
}
}

/**
* Clears all event listeners
*/
Expand Down Expand Up @@ -158,11 +180,61 @@ class ToolSocket {
}
});

this.addEventListener('meta', (route, body, _response, _binaryData, _messageBundle) => {
this.addEventListener('meta', (route, body, response, _binaryData, _messageBundle) => {
if (route === 'requestParallel') {
this.triggerEvent('requestParallel', body); // body = id
} else if (route === 'confirmParallel') {
this.triggerEvent('confirmParallel', body); // body = id
} else if (route === 'probe/down') {
// Throughput probe (see ToolSocketInfo.js): a large payload just
// arrived; a tiny acknowledgement lets the sender compute the
// downstream rate. Only runs when a probe is explicitly requested.
if (response) {
response.send('ok');
}
} else if (route === 'probe/up') {
// Throughput probe: the sender asks for `body` bytes of
// incompressible data to measure the upstream rate
if (response) {
response.send('ok', makeProbePayload(body));
}
} else if (route === 'info/report') {
// A server-info bundle pushed by the other side for a subscription
// created via the client-side info(true, callback) API
if (this.remoteInfoCallback) {
this.remoteInfoCallback(body);
}
} else if (route === 'info/subscribe') {
// Only meaningful on server-side sockets (this.server is set there)
if (this.server && this.server.subscribeServerInfo) {
this.server.subscribeServerInfo(this);
}
} else if (route === 'info/unsubscribe') {
if (this.server && this.server.unsubscribeServerInfo) {
this.server.unsubscribeServerInfo(this);
}
} else if (route === 'info/probe') {
// Client-requested staged throughput probe across all connections;
// the result is sent back as the response and also appears in the
// stagedProbe field of subsequent info/report bundles
if (this.server && this.server.stagedProbe) {
this.server.stagedProbe((result) => {
if (response) {
response.send(result);
}
}, body || {});
}
} else if (route === 'info/name') {
// The remote end names its own connection (e.g. the avatar or user
// it represents), sent via the client-side infoName() API. Stored on
// the socket — not the info handler — so it survives the info
// enable/disable cycles that come with subscribers joining/leaving.
const name = (body && typeof body.name === 'string' && body.name.length > 0)
? body.name.slice(0, 256) : null;
this.announcedInfoName = name;
if (this.infoHandler && this.infoHandler.setName) {
this.infoHandler.setName(name);
}
} else {
console.warn(`Received unknown meta route: "${route}"`);
}
Expand Down Expand Up @@ -626,6 +698,105 @@ class ToolSocket {
this.sendMethod('meta', route, body, callback, binaryData);
}

/**
* Client-side info API: asks the connected server to stream its info reports for
* ALL of its connections to this client via the given callback, every 5 seconds,
* until info(false) is called or this connection closes. Rides on ToolSocket's
* meta transport (routes info/subscribe, info/unsubscribe, info/report,
* info/probe, info/name) — the server only responds if it supports the info API. The
* subscription automatically re-arms after a reconnect. Note: there is no
* built-in authorization; gate access at the application level if needed.
* (On server-side IncomingToolSockets this method is overridden by the local
* per-connection info API.)
* @param {boolean} [enabled=false] - Start (true) or stop (false) the stream
* @param {?function} [infoCallback] - Receives {type: 'serverInfo', timestamp,
* connections, reports: [per-connection info report objects], recentlyClosed:
* [final reports of recently closed connections], stagedProbe}. Omit
* (undefined) to keep the current callback.
* @param {?Object} [options]
* @param {boolean} [options.probe] - Ask the server to run a staged throughput
* probe across its connections (results appear in stagedProbe and in each
* probed connection's data.probe)
* @param {number} [options.probeSizeBytes] - Payload per direction per probe
* @param {string[]} [options.probeNames] - Probe only the connections carrying
* one of these names (assigned via infoName()); omit to probe all
* @param {string[]} [options.probeIds] - Probe only the connections with one of
* these ids (data.id in the server's info reports); addresses any
* connection, named or not
* @param {boolean} [options.probeRamp] - Pass false to skip the growing 2, 4,
* 8... intermediate stages: the probe then measures each connection alone
* and all of them at once, nothing in between
*/
info(enabled = false, infoCallback, options) {
if (enabled) {
if (infoCallback !== undefined) {
this.remoteInfoCallback = infoCallback || null;
}
if (!this.remoteInfoSubscribed) {
this.remoteInfoSubscribed = true;
this.meta('info/subscribe', null);
if (!this.remoteInfoReattachArmed) {
// Re-subscribe automatically when the connection re-opens
this.remoteInfoReattachArmed = true;
this.addEventListener('open', () => {
if (this.remoteInfoSubscribed) {
this.meta('info/subscribe', null);
}
});
}
}
if (options && options.probe) {
const probeBody = {};
if (options.probeSizeBytes) probeBody.sizeBytes = options.probeSizeBytes;
if (Array.isArray(options.probeNames) && options.probeNames.length > 0) {
probeBody.names = options.probeNames.slice(0, 64);
}
if (Array.isArray(options.probeIds) && options.probeIds.length > 0) {
probeBody.ids = options.probeIds.slice(0, 128);
}
if (options.probeRamp === false) {
probeBody.ramp = false;
}
this.meta('info/probe', Object.keys(probeBody).length ? probeBody : null);
}
} else if (this.remoteInfoSubscribed) {
this.remoteInfoSubscribed = false;
this.remoteInfoCallback = null;
this.meta('info/unsubscribe', null);
}
}

/**
* Client-side info API: names this connection on the connected server (e.g. the
* avatar or user id this client represents). The server keeps the name on the
* connection and stamps it into every info report as data.name whenever info is
* active — independent of whether this client ever subscribes. One tiny meta
* message per call; automatically re-sent after a reconnect. Call it once when
* the client knows who it is.
* @param {?string} name - up to 256 chars; null or '' clears the name
*/
infoName(name) {
this.remoteInfoName = (typeof name === 'string' && name.length > 0)
? name.slice(0, 256) : null;
this.meta('info/name', {name: this.remoteInfoName});
if (!this.remoteInfoNameReattachArmed) {
// Re-introduce ourselves when the connection re-opens
this.remoteInfoNameReattachArmed = true;
this.addEventListener('open', () => {
if (this.remoteInfoName) {
this.meta('info/name', {name: this.remoteInfoName});
}
});
}
// parallel sockets belong to this connection: keep their names in sync so
// diagnostics group them under this name even when naming happens late
if (this.parallelSockets) {
for (const parallel of this.parallelSockets) {
parallel.infoName(this.remoteInfoName ? this.remoteInfoName + ' · data' : null);
}
}
}

/**
* Adds aliases for backwards compatibility
*/
Expand All @@ -646,7 +817,16 @@ class ToolSocket {
* @returns {ToolSocket} - A new ToolSocket created to the same endpoint as the original.
*/
static makeParallelSocket(toolsocket) {
return new ToolSocket(toolsocket.url, toolsocket.networkId, 'parallel');
const parallel = new ToolSocket(toolsocket.url, toolsocket.networkId, 'parallel');
// a parallel socket belongs to its source connection: track it and inherit
// the announced name (suffixed) so diagnostics group it under its parent —
// infoName() keeps the children in sync if the parent is named later
if (!toolsocket.parallelSockets) toolsocket.parallelSockets = [];
toolsocket.parallelSockets.push(parallel);
if (toolsocket.remoteInfoName) {
parallel.infoName(toolsocket.remoteInfoName + ' · data');
}
return parallel;
}
}

Expand Down
Loading
Loading