Files
meteor/packages/socket-stream-client/browser.js
dupontbertrand 86506f0918 ddp-server: pluggable transport architecture for WebSocket
Refactor the DDP transport layer into a pluggable architecture where
transports are isolated modules behind a common interface. This allows
benchmarking and comparing different WebSocket implementations on the
same build, same Node version, same machine.

Available transports (selectable via DDP_TRANSPORT env var or settings):
  - sockjs: SockJS (current default, backward compatible)
  - faye: faye-websocket (direct WebSocket, no SockJS overhead)
  - ws: ws npm package (most popular Node.js WebSocket library)
  - uws: uWebSockets.js (C++ high-performance transport)

Client-side: when transport != sockjs, SockJS is never loaded (dynamic
import), saving ~57 KB from the client bundle. Native WebSocket is used
instead.

Configuration:
  - DDP_TRANSPORT=ws (env var)
  - settings.packages['ddp-server'].transport = 'ws'
  - DISABLE_SOCKJS=1 still works (resolves to 'faye')

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:51:21 +01:00

220 lines
6.3 KiB
JavaScript

import {
toSockjsUrl,
toWebsocketUrl,
} from "./urls.js";
import { StreamClientCommon } from "./common.js";
// SockJS is imported statically to avoid the startup latency that dynamic
// import() would introduce in _launchConnection(). When a non-SockJS transport
// is selected, SockJS remains in the bundle but is never used — the connection
// goes through native WebSocket directly.
import SockJS from "./sockjs-1.6.1-min-.js";
export class ClientStream extends StreamClientCommon {
// @param url {String} URL to Meteor app
// "http://subdomain.meteor.com/" or "/" or
// "ddp+sockjs://foo-**.meteor.com/sockjs"
constructor(url, options) {
super(options);
this._initCommon(this.options);
//// Constants
// how long between hearing heartbeat from the server until we declare
// the connection dead. heartbeats come every 45s (stream_server.js)
//
// NOTE: this is a older timeout mechanism. We now send heartbeats at
// the DDP level (https://github.com/meteor/meteor/pull/1865), and
// expect those timeouts to kill a non-responsive connection before
// this timeout fires. This is kept around for compatibility (when
// talking to a server that doesn't support DDP heartbeats) and can be
// removed later.
this.HEARTBEAT_TIMEOUT = 100 * 1000;
this.rawUrl = url;
this.socket = null;
this.lastError = null;
this.heartbeatTimer = null;
// Listen to global 'online' event if we are running in a browser.
window.addEventListener(
'online',
this._online.bind(this),
false /* useCapture */
);
//// Kickoff!
this._launchConnection();
}
// data is a utf8 string. Data sent while not connected is dropped on
// the floor, and it is up the user of this API to retransmit lost
// messages on 'reset'
send(data) {
if (this.currentStatus.connected) {
this.socket.send(data);
}
}
// Changes where this connection points
_changeUrl(url) {
this.rawUrl = url;
}
_connected() {
if (this.connectionTimer) {
clearTimeout(this.connectionTimer);
this.connectionTimer = null;
}
if (this.currentStatus.connected) {
// already connected. do nothing. this probably shouldn't happen.
return;
}
// update status
this.currentStatus.status = 'connected';
this.currentStatus.connected = true;
this.currentStatus.retryCount = 0;
this.statusChanged();
// fire resets. This must come after status change so that clients
// can call send from within a reset callback.
this.forEachCallback('reset', callback => {
callback();
});
}
_cleanup(maybeError) {
this._clearConnectionAndHeartbeatTimers();
if (this.socket) {
this.socket.onmessage = this.socket.onclose = this.socket.onerror = this.socket.onheartbeat = () => {};
this.socket.close();
this.socket = null;
}
this.forEachCallback('disconnect', callback => {
callback(maybeError);
});
}
_clearConnectionAndHeartbeatTimers() {
if (this.connectionTimer) {
clearTimeout(this.connectionTimer);
this.connectionTimer = null;
}
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
_heartbeat_timeout() {
console.log('Connection timeout. No sockjs heartbeat received.');
this._lostConnection(new this.ConnectionError("Heartbeat timed out"));
}
_heartbeat_received() {
// If we've already permanently shut down this stream, the timeout is
// already cleared, and we don't need to set it again.
if (this._forcedToDisconnect) return;
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
this.heartbeatTimer = setTimeout(
this._heartbeat_timeout.bind(this),
this.HEARTBEAT_TIMEOUT
);
}
_sockjsProtocolsWhitelist() {
// only allow polling protocols. no streaming. streaming
// makes safari spin.
var protocolsWhitelist = [
'xdr-polling',
'xhr-polling',
'iframe-xhr-polling',
'jsonp-polling'
];
// iOS 4 and 5 and below crash when using websockets over certain
// proxies. this seems to be resolved with iOS 6. eg
// https://github.com/LearnBoost/socket.io/issues/193#issuecomment-7308865.
//
// iOS <4 doesn't support websockets at all so sockjs will just
// immediately fall back to http
var noWebsockets =
navigator &&
/iPhone|iPad|iPod/.test(navigator.userAgent) &&
/OS 4_|OS 5_/.test(navigator.userAgent);
if (!noWebsockets)
protocolsWhitelist = ['websocket'].concat(protocolsWhitelist);
return protocolsWhitelist;
}
_launchConnection() {
this._cleanup(); // cleanup the old socket, if there was one.
const transport = __meteor_runtime_config__.DDP_TRANSPORT || 'sockjs';
if (transport === 'sockjs') {
const options = {
transports: this._sockjsProtocolsWhitelist(),
...this.options._sockjsOptions
};
// Convert raw URL to SockJS URL each time we open a connection, so
// that we can connect to random hostnames and get around browser
// per-host connection limits.
this.socket = new SockJS(toSockjsUrl(this.rawUrl), undefined, options);
} else {
// All non-SockJS transports use native WebSocket on the client.
this.socket = new WebSocket(toWebsocketUrl(this.rawUrl));
}
this.socket.onopen = data => {
this.lastError = null;
this._connected();
};
this.socket.onmessage = data => {
this.lastError = null;
this._heartbeat_received();
if (this.currentStatus.connected) {
this.forEachCallback('message', callback => {
callback(data.data);
});
}
};
this.socket.onclose = () => {
this._lostConnection();
};
this.socket.onerror = error => {
const { lastError } = this;
this.lastError = error;
if (lastError) return;
console.error(
'stream error',
error,
new Date().toDateString()
);
};
this.socket.onheartbeat = () => {
this.lastError = null;
this._heartbeat_received();
};
if (this.connectionTimer) clearTimeout(this.connectionTimer);
this.connectionTimer = setTimeout(() => {
this._lostConnection(
new this.ConnectionError("DDP connection timed out")
);
}, this.CONNECT_TIMEOUT);
}
}