@libp2p/websockets
Version:
JavaScript implementation of the WebSockets module that libp2p uses and that implements the interface-transport spec
308 lines • 12.2 kB
JavaScript
import http from 'node:http';
import https from 'node:https';
import net from 'node:net';
import { getThinWaistAddresses } from '@libp2p/utils/get-thin-waist-addresses';
import { ipPortToMultiaddr as toMultiaddr } from '@libp2p/utils/ip-port-to-multiaddr';
import { multiaddr } from '@multiformats/multiaddr';
import { WebSockets, WebSocketsSecure } from '@multiformats/multiaddr-matcher';
import duplex from 'it-ws/duplex';
import { TypedEventEmitter, setMaxListeners } from 'main-event';
import { pEvent } from 'p-event';
import * as ws from 'ws';
import { socketToMaConn } from './socket-to-conn.js';
export class WebSocketListener extends TypedEventEmitter {
log;
logger;
server;
wsServer;
metrics;
sockets;
upgrader;
httpOptions;
httpsOptions;
shutdownController;
http;
https;
addr;
listeningMultiaddr;
constructor(components, init) {
super();
this.log = components.logger.forComponent('libp2p:websockets:listener');
this.logger = components.logger;
this.upgrader = init.upgrader;
this.httpOptions = init.http;
this.httpsOptions = init.https ?? init.http;
this.sockets = new Set();
this.shutdownController = new AbortController();
setMaxListeners(Infinity, this.shutdownController.signal);
this.wsServer = new ws.WebSocketServer({
noServer: true
});
this.wsServer.addListener('connection', this.onWsServerConnection.bind(this));
components.metrics?.registerMetricGroup('libp2p_websockets_inbound_connections_total', {
label: 'address',
help: 'Current active connections in WebSocket listener',
calculate: () => {
if (this.addr == null) {
return {};
}
return {
[this.addr]: this.sockets.size
};
}
});
this.metrics = {
status: components.metrics?.registerMetricGroup('libp2p_websockets_listener_status_info', {
label: 'address',
help: 'Current status of the WebSocket listener socket'
}),
errors: components.metrics?.registerMetricGroup('libp2p_websockets_listener_errors_total', {
label: 'address',
help: 'Total count of WebSocket listener errors by type'
}),
events: components.metrics?.registerMetricGroup('libp2p_websockets_listener_events_total', {
label: 'address',
help: 'Total count of WebSocket listener events by type'
})
};
this.server = net.createServer({
pauseOnConnect: true
}, (socket) => {
this.onSocketConnection(socket)
.catch(err => {
this.log.error('error handling socket - %e', err);
socket.destroy();
});
});
components.events.addEventListener('certificate:provision', this.onCertificateProvision.bind(this));
components.events.addEventListener('certificate:renew', this.onCertificateRenew.bind(this));
}
async onSocketConnection(socket) {
this.metrics.events?.increment({ [`${this.addr} connection`]: true });
let buffer = socket.read(1);
if (buffer == null) {
await pEvent(socket, 'readable');
buffer = socket.read(1);
}
// determine if this is an HTTP(s) request
const byte = buffer[0];
let server = this.http;
// https://github.com/mscdex/httpolyglot/blob/1c6c4af65f4cf95a32c918d0fdcc532e0c095740/lib/index.js#L92
if (byte < 32 || byte >= 127) {
server = this.https;
}
if (server == null) {
this.log.error('no appropriate listener configured for byte %d', byte);
socket.destroy();
return;
}
// store the socket so we can close it when the listener closes
this.sockets.add(socket);
socket.on('close', () => {
this.metrics.events?.increment({ [`${this.addr} close`]: true });
this.sockets.delete(socket);
});
socket.on('error', (err) => {
this.log.error('socket error - %e', err);
this.metrics.events?.increment({ [`${this.addr} error`]: true });
socket.destroy();
});
socket.once('timeout', () => {
this.metrics.events?.increment({ [`${this.addr} timeout`]: true });
});
socket.once('end', () => {
this.metrics.events?.increment({ [`${this.addr} end`]: true });
});
// re-queue first data chunk
socket.unshift(buffer);
// hand the socket off to the appropriate server
server.emit('connection', socket);
}
onWsServerConnection(socket, req) {
let addr;
try {
addr = this.server.address();
if (typeof addr === 'string') {
throw new Error('Cannot listen on unix sockets');
}
if (addr == null) {
throw new Error('Server was closing or not running');
}
}
catch (err) {
this.log.error('error obtaining remote socket address - %e', err);
req.destroy(err);
socket.close();
return;
}
const stream = {
...duplex(socket, {
remoteAddress: req.socket.remoteAddress ?? '0.0.0.0',
remotePort: req.socket.remotePort ?? 0
}),
localAddress: addr.address,
localPort: addr.port
};
let maConn;
try {
maConn = socketToMaConn(stream, toMultiaddr(stream.remoteAddress ?? '', stream.remotePort ?? 0), {
logger: this.logger,
metrics: this.metrics?.events,
metricPrefix: `${this.addr} `
});
}
catch (err) {
this.log.error('inbound connection failed', err);
this.metrics.errors?.increment({ [`${this.addr} inbound_to_connection`]: true });
socket.close();
return;
}
this.log('new inbound connection %s', maConn.remoteAddr);
this.upgrader.upgradeInbound(maConn, {
signal: this.shutdownController.signal
})
.catch(async (err) => {
this.log.error('inbound connection failed to upgrade - %e', err);
this.metrics.errors?.increment({ [`${this.addr} inbound_upgrade`]: true });
await maConn.close()
.catch(err => {
this.log.error('inbound connection failed to close after upgrade failed', err);
this.metrics.errors?.increment({ [`${this.addr} inbound_closing_failed`]: true });
});
});
}
onUpgrade(req, socket, head) {
this.wsServer.handleUpgrade(req, socket, head, this.onWsServerConnection.bind(this));
}
onTLSClientError(err, socket) {
this.log.error('TLS client error - %e', err);
socket.destroy();
}
async listen(ma) {
if (WebSockets.exactMatch(ma)) {
this.http = http.createServer(this.httpOptions ?? {}, this.httpRequestHandler.bind(this));
this.http.addListener('upgrade', this.onUpgrade.bind(this));
}
else if (WebSocketsSecure.exactMatch(ma)) {
this.https = https.createServer(this.httpsOptions ?? {}, this.httpRequestHandler.bind(this));
this.https.addListener('upgrade', this.onUpgrade.bind(this));
this.https.addListener('tlsClientError', this.onTLSClientError.bind(this));
}
const options = ma.toOptions();
this.addr = `${options.host}:${options.port}`;
this.server.listen({
...options,
ipv6Only: options.family === 6
});
await new Promise((resolve, reject) => {
const onListening = () => {
removeListeners();
resolve();
};
const onError = (err) => {
this.metrics.errors?.increment({ [`${this.addr} listen_error`]: true });
removeListeners();
reject(err);
};
const onDrop = () => {
this.metrics.events?.increment({ [`${this.addr} drop`]: true });
};
const removeListeners = () => {
this.server.removeListener('listening', onListening);
this.server.removeListener('error', onError);
this.server.removeListener('drop', onDrop);
};
this.server.addListener('listening', onListening);
this.server.addListener('error', onError);
this.server.addListener('drop', onDrop);
});
this.listeningMultiaddr = ma;
this.safeDispatchEvent('listening');
}
onCertificateProvision(event) {
if (this.https != null) {
this.log('auto-tls certificate found but already listening on https');
return;
}
this.log('auto-tls certificate found, starting https server');
this.https = https.createServer({
...this.httpsOptions,
...event.detail
}, this.httpRequestHandler.bind(this));
this.https.addListener('upgrade', this.onUpgrade.bind(this));
this.https.addListener('tlsClientError', this.onTLSClientError.bind(this));
this.safeDispatchEvent('listening');
}
onCertificateRenew(event) {
// stop accepting new connections
this.https?.close();
this.log('auto-tls certificate renewed, restarting https server');
this.https = https.createServer({
...this.httpsOptions,
...event.detail
}, this.httpRequestHandler.bind(this));
this.https.addListener('upgrade', this.onUpgrade.bind(this));
this.https.addListener('tlsClientError', this.onTLSClientError.bind(this));
}
async close() {
this.server.close();
this.http?.close();
this.https?.close();
this.wsServer.close();
// close all connections, must be done after closing the server to prevent
// race conditions where a new connection is accepted while we are closing
// the existing ones
this.http?.closeAllConnections();
this.https?.closeAllConnections();
[...this.sockets].forEach(socket => {
socket.destroy();
});
// abort and in-flight connection upgrades
this.shutdownController.abort();
await Promise.all([
pEvent(this.server, 'close'),
this.http == null ? null : pEvent(this.http, 'close'),
this.https == null ? null : pEvent(this.https, 'close'),
pEvent(this.wsServer, 'close')
]);
this.safeDispatchEvent('close');
}
getAddrs() {
const address = this.server.address();
if (address == null) {
return [];
}
if (typeof address === 'string') {
// TODO: wrap with encodeURIComponent https://github.com/multiformats/multiaddr/pull/174
return [multiaddr(`/unix/${address}/ws`)];
}
const multiaddrs = getThinWaistAddresses(this.listeningMultiaddr, address.port);
const insecureMultiaddrs = [];
if (this.http != null) {
multiaddrs.forEach(ma => {
insecureMultiaddrs.push(ma.encapsulate('/ws'));
});
}
const secureMultiaddrs = [];
if (this.https != null) {
multiaddrs.forEach(ma => {
secureMultiaddrs.push(ma.encapsulate('/tls/ws'));
});
}
return [
...insecureMultiaddrs,
...secureMultiaddrs
];
}
updateAnnounceAddrs() {
}
httpRequestHandler(req, res) {
res.writeHead(400);
res.write('Only WebSocket connections are supported');
res.end();
}
}
export function createListener(components, init) {
return new WebSocketListener(components, init);
}
//# sourceMappingURL=listener.js.map