Fork @iov/socket (#250)
* socket: Fork @iov/socket * socket: Remove nonces * socket: Update package.json * socket: Update README * socket: Remove tslint * socket: Fix lint warnings * scripts: Fork socketserver from IOV Core * root: Update NOTICE for socket * tendermint-rpc: Replace @iov/socket dependency with @cosmjs/socket * root: Update CI config for tendermint/socket * scripts: Add wait to tendermint start script * socket: Add coverage
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
export { ConnectionStatus, QueueingStreamingSocket } from "./queueingstreamingsocket";
|
||||
export { ReconnectingSocket } from "./reconnectingsocket";
|
||||
export {
|
||||
SocketWrapper,
|
||||
SocketWrapperCloseEvent,
|
||||
SocketWrapperErrorEvent,
|
||||
SocketWrapperMessageEvent,
|
||||
} from "./socketwrapper";
|
||||
export { StreamingSocket } from "./streamingsocket";
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ConnectionStatus, QueueingStreamingSocket } from "./queueingstreamingsocket";
|
||||
|
||||
function pendingWithoutSocketServer(): void {
|
||||
if (!process.env.SOCKETSERVER_ENABLED) {
|
||||
pending("Set SOCKETSERVER_ENABLED to enable socket tests");
|
||||
}
|
||||
}
|
||||
|
||||
describe("QueueingStreamingSocket", () => {
|
||||
const socketServerUrl = "ws://localhost:4444/websocket";
|
||||
|
||||
it("can be constructed", () => {
|
||||
const socket = new QueueingStreamingSocket(socketServerUrl);
|
||||
expect(socket).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("queueRequest", () => {
|
||||
it("can queue and process requests with a connection", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new QueueingStreamingSocket(socketServerUrl);
|
||||
const requests = ["request 1", "request 2", "request 3"] as const;
|
||||
let eventsSeen = 0;
|
||||
socket.events.subscribe({
|
||||
next: (event) => {
|
||||
expect(event.data).toEqual(requests[eventsSeen++]);
|
||||
if (eventsSeen === requests.length) {
|
||||
expect(socket.getQueueLength()).toEqual(0);
|
||||
socket.disconnect();
|
||||
done();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
requests.forEach((request) => socket.queueRequest(request));
|
||||
});
|
||||
|
||||
it("can queue requests without a connection and process them later", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new QueueingStreamingSocket(socketServerUrl);
|
||||
const requests = ["request 1", "request 2", "request 3"] as const;
|
||||
let eventsSeen = 0;
|
||||
socket.events.subscribe({
|
||||
next: (event) => {
|
||||
expect(event.data).toEqual(requests[eventsSeen++]);
|
||||
if (eventsSeen === requests.length) {
|
||||
expect(socket.getQueueLength()).toEqual(0);
|
||||
socket.disconnect();
|
||||
done();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
requests.forEach((request) => socket.queueRequest(request));
|
||||
setTimeout(() => {
|
||||
expect(socket.getQueueLength()).toEqual(3);
|
||||
socket.connect();
|
||||
}, 5_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnect", () => {
|
||||
it("does not emit a completed event when disconnected", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const request = "request";
|
||||
const socket = new QueueingStreamingSocket(socketServerUrl);
|
||||
socket.events.subscribe({
|
||||
next: ({ data }) => {
|
||||
if (data === request) {
|
||||
socket.disconnect();
|
||||
done();
|
||||
}
|
||||
},
|
||||
complete: () => done.fail("Stream completed"),
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
socket.disconnect();
|
||||
socket.reconnect();
|
||||
socket.queueRequest(request);
|
||||
});
|
||||
|
||||
it("can reconnect and process remaining queue", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new QueueingStreamingSocket(socketServerUrl);
|
||||
const requests = ["request 1", "request 2", "request 3"] as const;
|
||||
let eventsSeen = 0;
|
||||
|
||||
socket.connect();
|
||||
socket.disconnect();
|
||||
|
||||
requests.forEach((request) => socket.queueRequest(request));
|
||||
|
||||
socket.events.subscribe({
|
||||
next: (event) => {
|
||||
expect(event.data).toEqual(requests[eventsSeen++]);
|
||||
if (eventsSeen === requests.length) {
|
||||
expect(socket.getQueueLength()).toEqual(0);
|
||||
socket.disconnect();
|
||||
done();
|
||||
}
|
||||
},
|
||||
});
|
||||
socket.reconnect();
|
||||
});
|
||||
|
||||
it("notifies on reconnection via a callback", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new QueueingStreamingSocket(socketServerUrl, undefined, done);
|
||||
|
||||
socket.reconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionStatus", () => {
|
||||
it("exposes connection status", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new QueueingStreamingSocket(socketServerUrl);
|
||||
let statusChangesSeen = 0;
|
||||
socket.connectionStatus.updates.subscribe({
|
||||
next: (status) => {
|
||||
switch (statusChangesSeen++) {
|
||||
case 0:
|
||||
expect(status).toEqual(ConnectionStatus.Unconnected);
|
||||
break;
|
||||
case 1:
|
||||
case 4:
|
||||
expect(status).toEqual(ConnectionStatus.Connecting);
|
||||
break;
|
||||
case 2:
|
||||
case 5:
|
||||
expect(status).toEqual(ConnectionStatus.Connected);
|
||||
break;
|
||||
case 3:
|
||||
case 6:
|
||||
expect(status).toEqual(ConnectionStatus.Disconnected);
|
||||
break;
|
||||
default:
|
||||
done.fail("Got too many status changes");
|
||||
}
|
||||
if (statusChangesSeen === 7) {
|
||||
done();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
setTimeout(() => {
|
||||
socket.disconnect();
|
||||
socket.reconnect();
|
||||
setTimeout(() => socket.disconnect(), 1000);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { DefaultValueProducer, ValueAndUpdates } from "@iov/stream";
|
||||
import { Listener, Producer, Stream } from "xstream";
|
||||
|
||||
import { SocketWrapperMessageEvent } from "./socketwrapper";
|
||||
import { StreamingSocket } from "./streamingsocket";
|
||||
|
||||
export enum ConnectionStatus {
|
||||
Unconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around StreamingSocket that can queue requests.
|
||||
*/
|
||||
export class QueueingStreamingSocket {
|
||||
public readonly connectionStatus: ValueAndUpdates<ConnectionStatus>;
|
||||
public readonly events: Stream<SocketWrapperMessageEvent>;
|
||||
|
||||
private readonly url: string;
|
||||
private readonly timeout: number;
|
||||
private readonly queue: string[] = [];
|
||||
private socket: StreamingSocket;
|
||||
private isProcessingQueue = false;
|
||||
private eventProducerListener: Listener<SocketWrapperMessageEvent> | undefined;
|
||||
private readonly connectionStatusProducer: DefaultValueProducer<ConnectionStatus>;
|
||||
private readonly reconnectedHandler?: () => void;
|
||||
|
||||
public constructor(url: string, timeout = 10_000, reconnectedHandler?: () => void) {
|
||||
this.url = url;
|
||||
this.timeout = timeout;
|
||||
this.reconnectedHandler = reconnectedHandler;
|
||||
|
||||
const eventProducer: Producer<any> = {
|
||||
start: (listener) => (this.eventProducerListener = listener),
|
||||
stop: () => (this.eventProducerListener = undefined),
|
||||
};
|
||||
this.events = Stream.create(eventProducer);
|
||||
this.connectionStatusProducer = new DefaultValueProducer<ConnectionStatus>(ConnectionStatus.Unconnected);
|
||||
this.connectionStatus = new ValueAndUpdates(this.connectionStatusProducer);
|
||||
|
||||
this.socket = new StreamingSocket(this.url, this.timeout);
|
||||
this.socket.events.subscribe({
|
||||
next: (event) => {
|
||||
if (!this.eventProducerListener) throw new Error("No event producer listener set");
|
||||
this.eventProducerListener.next(event);
|
||||
},
|
||||
error: () => this.connectionStatusProducer.update(ConnectionStatus.Disconnected),
|
||||
});
|
||||
}
|
||||
|
||||
public connect(): void {
|
||||
this.connectionStatusProducer.update(ConnectionStatus.Connecting);
|
||||
this.socket.connected.then(
|
||||
async () => {
|
||||
this.connectionStatusProducer.update(ConnectionStatus.Connected);
|
||||
return this.processQueue();
|
||||
},
|
||||
() => this.connectionStatusProducer.update(ConnectionStatus.Disconnected),
|
||||
);
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.connectionStatusProducer.update(ConnectionStatus.Disconnected);
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
public reconnect(): void {
|
||||
this.socket = new StreamingSocket(this.url, this.timeout);
|
||||
this.socket.events.subscribe({
|
||||
next: (event) => {
|
||||
if (!this.eventProducerListener) throw new Error("No event producer listener set");
|
||||
this.eventProducerListener.next(event);
|
||||
},
|
||||
error: () => this.connectionStatusProducer.update(ConnectionStatus.Disconnected),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.socket.connected.then(() => {
|
||||
if (this.reconnectedHandler) {
|
||||
this.reconnectedHandler();
|
||||
}
|
||||
});
|
||||
this.connect();
|
||||
}
|
||||
|
||||
public getQueueLength(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
public queueRequest(request: string): void {
|
||||
this.queue.push(request);
|
||||
// We don’t need to wait for the queue to be processed.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.connectionStatus.value !== ConnectionStatus.Connected) {
|
||||
return;
|
||||
}
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
let request: string | undefined;
|
||||
while ((request = this.queue.shift())) {
|
||||
try {
|
||||
await this.socket.send(request);
|
||||
this.isProcessingQueue = false;
|
||||
} catch (error) {
|
||||
// Probably the connection is down; will try again automatically when reconnected.
|
||||
this.queue.unshift(request);
|
||||
this.isProcessingQueue = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from "assert";
|
||||
|
||||
import { ReconnectingSocket } from "./reconnectingsocket";
|
||||
|
||||
/** @see https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback */
|
||||
type Exec = (command: string, callback: (error: null | (Error & { readonly code: number })) => void) => void;
|
||||
|
||||
let exec: Exec | undefined;
|
||||
let childProcessAvailable: boolean;
|
||||
|
||||
try {
|
||||
exec = require("child_process").exec;
|
||||
assert.strict(typeof exec === "function");
|
||||
childProcessAvailable = true;
|
||||
} catch {
|
||||
childProcessAvailable = false;
|
||||
}
|
||||
|
||||
function pendingWithoutSocketServer(): void {
|
||||
if (!process.env.SOCKETSERVER_ENABLED) {
|
||||
pending("Set SOCKETSERVER_ENABLED to enable socket tests");
|
||||
}
|
||||
}
|
||||
|
||||
function pendingWithoutChildProcess(): void {
|
||||
if (!childProcessAvailable) {
|
||||
pending("Run test in an environment which supports child processes to enable socket tests");
|
||||
}
|
||||
}
|
||||
|
||||
describe("ReconnectingSocket", () => {
|
||||
const socketServerUrl = "ws://localhost:4444/websocket";
|
||||
|
||||
it("can be constructed", () => {
|
||||
const socket = new ReconnectingSocket(socketServerUrl);
|
||||
expect(socket).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("connect", () => {
|
||||
it("cannot connect after being connected", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new ReconnectingSocket(socketServerUrl);
|
||||
// Necessary otherwise the producer doesn’t start
|
||||
socket.events.subscribe({});
|
||||
|
||||
socket.connect();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(() => socket.connect()).toThrowError(/cannot connect/i);
|
||||
done();
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnect", () => {
|
||||
it("ends the events stream", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new ReconnectingSocket(socketServerUrl);
|
||||
socket.events.subscribe({
|
||||
complete: done,
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
|
||||
setTimeout(() => socket.disconnect(), 1000);
|
||||
});
|
||||
|
||||
it("cannot connect after being disconnected", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new ReconnectingSocket(socketServerUrl);
|
||||
// Necessary otherwise the producer doesn’t start
|
||||
socket.events.subscribe({});
|
||||
|
||||
socket.connect();
|
||||
|
||||
setTimeout(() => {
|
||||
socket.disconnect();
|
||||
expect(() => socket.connect()).toThrowError(/cannot connect/i);
|
||||
done();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
it("can disconnect without waiting for open", () => {
|
||||
pendingWithoutSocketServer();
|
||||
const socket = new ReconnectingSocket(socketServerUrl);
|
||||
expect(() => {
|
||||
socket.connect();
|
||||
socket.disconnect();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnection", () => {
|
||||
const dirPath = "../../scripts/socketserver";
|
||||
const PKILL_NO_PROCESSES_MATCHED = 1;
|
||||
const startServerCmd = `${dirPath}/start.sh`;
|
||||
const stopServerCmd = `${dirPath}/stop.sh`;
|
||||
|
||||
it("automatically reconnects if no connection can be established at init", (done) => {
|
||||
pendingWithoutChildProcess();
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
exec!(stopServerCmd, (stopError) => {
|
||||
if (stopError && stopError.code !== PKILL_NO_PROCESSES_MATCHED) {
|
||||
done.fail(stopError);
|
||||
}
|
||||
|
||||
const socket = new ReconnectingSocket(socketServerUrl);
|
||||
const requests = ["request 1", "request 2", "request 3"] as const;
|
||||
let eventsSeen = 0;
|
||||
socket.events.subscribe({
|
||||
next: ({ data }) => {
|
||||
expect(data).toEqual(requests[eventsSeen++]);
|
||||
if (eventsSeen === requests.length) {
|
||||
socket.disconnect();
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
// Make sure we don't get a completion unexpectedly
|
||||
expect(eventsSeen).toEqual(requests.length);
|
||||
done();
|
||||
},
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
requests.forEach((request) => socket.queueRequest(request));
|
||||
|
||||
setTimeout(
|
||||
() =>
|
||||
exec!(startServerCmd, (startError) => {
|
||||
if (startError) {
|
||||
done.fail(startError);
|
||||
}
|
||||
}),
|
||||
2000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("automatically reconnects if the connection is broken off", (done) => {
|
||||
pendingWithoutChildProcess();
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new ReconnectingSocket(socketServerUrl);
|
||||
const requests = ["request 1", "request 2", "request 3"] as const;
|
||||
let eventsSeen = 0;
|
||||
socket.events.subscribe({
|
||||
next: ({ data }) => {
|
||||
expect(data).toEqual(requests[eventsSeen++]);
|
||||
if (eventsSeen === requests.length) {
|
||||
socket.disconnect();
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
// Make sure we don't get a completion unexpectedly
|
||||
expect(eventsSeen).toEqual(requests.length);
|
||||
done();
|
||||
},
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
socket.queueRequest(requests[0]);
|
||||
|
||||
setTimeout(
|
||||
() =>
|
||||
exec!(stopServerCmd, (stopError) => {
|
||||
if (stopError && stopError.code !== PKILL_NO_PROCESSES_MATCHED) {
|
||||
done.fail(stopError);
|
||||
}
|
||||
|
||||
// TODO: This timeout is here to avoid an edge case where if a request
|
||||
// is sent just as a disconnection occurs, then the websocket’s `send`
|
||||
// method may not error even though the request is never sent.
|
||||
// Ideally we would have a way to cover this edge case and the timeout
|
||||
// would not be necessary for this test to pass.
|
||||
setTimeout(() => {
|
||||
requests.slice(1).forEach((request) => socket.queueRequest(request));
|
||||
|
||||
setTimeout(
|
||||
() =>
|
||||
exec!(startServerCmd, (startError) => {
|
||||
if (startError) {
|
||||
done.fail(startError);
|
||||
}
|
||||
}),
|
||||
2000,
|
||||
);
|
||||
}, 2000);
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ValueAndUpdates } from "@iov/stream";
|
||||
import { Listener, Producer, Stream } from "xstream";
|
||||
|
||||
import { ConnectionStatus, QueueingStreamingSocket } from "./queueingstreamingsocket";
|
||||
import { SocketWrapperMessageEvent } from "./socketwrapper";
|
||||
|
||||
/**
|
||||
* A wrapper around QueueingStreamingSocket that reconnects automatically.
|
||||
*/
|
||||
export class ReconnectingSocket {
|
||||
/** Starts with a 0.1 second timeout, then doubles every attempt with a maximum timeout of 5 seconds. */
|
||||
private static calculateTimeout(index: number): number {
|
||||
return Math.min(2 ** index * 100, 5_000);
|
||||
}
|
||||
|
||||
public readonly connectionStatus: ValueAndUpdates<ConnectionStatus>;
|
||||
public readonly events: Stream<SocketWrapperMessageEvent>;
|
||||
|
||||
private readonly socket: QueueingStreamingSocket;
|
||||
private eventProducerListener: Listener<SocketWrapperMessageEvent> | undefined;
|
||||
private unconnected = true;
|
||||
private disconnected = false;
|
||||
private timeoutIndex = 0;
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
public constructor(url: string, timeout = 10_000, reconnectedHandler?: () => void) {
|
||||
const eventProducer: Producer<any> = {
|
||||
start: (listener) => (this.eventProducerListener = listener),
|
||||
stop: () => (this.eventProducerListener = undefined),
|
||||
};
|
||||
this.events = Stream.create(eventProducer);
|
||||
|
||||
this.socket = new QueueingStreamingSocket(url, timeout, reconnectedHandler);
|
||||
this.socket.events.subscribe({
|
||||
next: (event) => {
|
||||
if (this.eventProducerListener) {
|
||||
this.eventProducerListener.next(event);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (this.eventProducerListener) {
|
||||
this.eventProducerListener.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.connectionStatus = this.socket.connectionStatus;
|
||||
this.connectionStatus.updates.subscribe({
|
||||
next: (status) => {
|
||||
if (status === ConnectionStatus.Connected) {
|
||||
this.timeoutIndex = 0;
|
||||
}
|
||||
if (status === ConnectionStatus.Disconnected) {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
this.reconnectTimeout = setTimeout(
|
||||
() => this.socket.reconnect(),
|
||||
ReconnectingSocket.calculateTimeout(this.timeoutIndex++),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public connect(): void {
|
||||
if (!this.unconnected) {
|
||||
throw new Error("Cannot connect: socket has already connected");
|
||||
}
|
||||
this.socket.connect();
|
||||
this.unconnected = false;
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
if (this.unconnected) {
|
||||
throw new Error("Cannot disconnect: socket has not yet connected");
|
||||
}
|
||||
this.socket.disconnect();
|
||||
if (this.eventProducerListener) {
|
||||
this.eventProducerListener.complete();
|
||||
}
|
||||
this.disconnected = true;
|
||||
}
|
||||
|
||||
public queueRequest(request: string): void {
|
||||
if (this.disconnected) {
|
||||
throw new Error("Cannot queue request: socket has disconnected");
|
||||
}
|
||||
this.socket.queueRequest(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { SocketWrapper } from "./socketwrapper";
|
||||
|
||||
function pendingWithoutSocketServer(): void {
|
||||
if (!process.env.SOCKETSERVER_ENABLED) {
|
||||
pending("Set SOCKETSERVER_ENABLED to enable socket tests");
|
||||
}
|
||||
}
|
||||
|
||||
describe("SocketWrapper", () => {
|
||||
const socketServerUrlNonExisting = "ws://localhost:4443/websocket";
|
||||
const socketServerUrl = "ws://localhost:4444/websocket";
|
||||
const socketServerUrlSlow = "ws://localhost:4445/websocket";
|
||||
|
||||
it("can be constructed", () => {
|
||||
const socket = new SocketWrapper(socketServerUrl, fail, fail);
|
||||
expect(socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can connect", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrl,
|
||||
() => done.fail("Got unexpected message event"),
|
||||
(error) => done.fail(error.message || "Unknown socket error"),
|
||||
() => {
|
||||
socket.disconnect();
|
||||
done();
|
||||
},
|
||||
);
|
||||
expect(socket).toBeTruthy();
|
||||
socket.connect();
|
||||
});
|
||||
|
||||
it("fails to connect to non-existing server", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrlNonExisting,
|
||||
() => done.fail("Got unexpected message event"),
|
||||
(error) => {
|
||||
if (error.message) {
|
||||
// error message only available in nodejs
|
||||
expect(error.message).toMatch(/ECONNREFUSED/i);
|
||||
}
|
||||
done();
|
||||
},
|
||||
() => done.fail("Got unexpected open event"),
|
||||
);
|
||||
expect(socket).toBeTruthy();
|
||||
socket.connect();
|
||||
});
|
||||
|
||||
it("fails to connect to non-existing server but timeout is not triggered", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const timeout = 1200; // ms
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrlNonExisting,
|
||||
() => done.fail("Got unexpected message event"),
|
||||
(error) => {
|
||||
expect(error).toBeTruthy();
|
||||
|
||||
// All done. Delay test end to ensure the timeout is not triggered
|
||||
setTimeout(done, timeout * 1.3);
|
||||
},
|
||||
() => done.fail("Got unexpected open event"),
|
||||
() => 0,
|
||||
timeout,
|
||||
);
|
||||
expect(socket).toBeTruthy();
|
||||
socket.connect();
|
||||
});
|
||||
|
||||
it("can connect to slow server", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrlSlow,
|
||||
() => done.fail("Got unexpected message event"),
|
||||
(error) => done.fail(error.message || "Unknown socket error"),
|
||||
() => {
|
||||
socket.disconnect();
|
||||
done();
|
||||
},
|
||||
);
|
||||
expect(socket).toBeTruthy();
|
||||
socket.connect();
|
||||
});
|
||||
|
||||
it("times out when establishing connection takes too long", async () => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrlSlow,
|
||||
() => fail("Got unexpected message event"),
|
||||
(error) => fail(error.message || "Unknown socket error"),
|
||||
() => fail("Got unexpected opened event"),
|
||||
() => fail("Got unexpected closed event"),
|
||||
2_000,
|
||||
);
|
||||
socket.connect();
|
||||
|
||||
await socket.connected
|
||||
.then(() => fail("must not resolve"))
|
||||
.catch((error) => expect(error).toMatch(/connection attempt timed out/i));
|
||||
});
|
||||
|
||||
it("can connect and disconnect", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
let opened = 0;
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrl,
|
||||
() => done.fail("Got unexpected message event"),
|
||||
(error) => done.fail(error.message || "Unknown socket error"),
|
||||
() => {
|
||||
opened += 1;
|
||||
socket.disconnect();
|
||||
},
|
||||
(closeEvent) => {
|
||||
expect(closeEvent.wasClean).toEqual(true);
|
||||
expect(closeEvent.code).toEqual(1000 /* Normal Closure */);
|
||||
|
||||
expect(opened).toEqual(1);
|
||||
done();
|
||||
},
|
||||
);
|
||||
socket.connect();
|
||||
});
|
||||
|
||||
it("can disconnect before waiting for open", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrl,
|
||||
() => done.fail("Got unexpected message event"),
|
||||
(error) => done.fail(error.message || "Unknown socket error"),
|
||||
() => done.fail("Got unexpected open event"),
|
||||
(closeEvent) => {
|
||||
expect(closeEvent.wasClean).toEqual(false);
|
||||
expect(closeEvent.code).toEqual(4001);
|
||||
done();
|
||||
},
|
||||
);
|
||||
socket.connect();
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
it("can disconnect before waiting for open and timeout will not be triggered", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
const timeout = 500; // ms
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrl,
|
||||
() => done.fail("Got unexpected message event"),
|
||||
(error) => done.fail(error.message || "Unknown socket error"),
|
||||
() => done.fail("Got unexpected open event"),
|
||||
(closeEvent) => {
|
||||
expect(closeEvent.wasClean).toEqual(false);
|
||||
expect(closeEvent.code).toEqual(4001);
|
||||
|
||||
// All done. Delay test end to ensure the timeout is not triggered
|
||||
setTimeout(done, timeout * 1.3);
|
||||
},
|
||||
timeout,
|
||||
);
|
||||
socket.connect();
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
it("can send events when connected", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const responseMessages = new Array<string>();
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrl,
|
||||
(response) => {
|
||||
expect(response.type).toEqual("message");
|
||||
responseMessages.push(response.data);
|
||||
|
||||
if (responseMessages.length === 3) {
|
||||
socket.disconnect();
|
||||
}
|
||||
},
|
||||
(error) => done.fail(error.message || "Unknown socket error"),
|
||||
async () => {
|
||||
await socket.send("aabbccdd");
|
||||
await socket.send("whatever");
|
||||
await socket.send("lalala");
|
||||
},
|
||||
() => {
|
||||
expect(responseMessages.length).toEqual(3);
|
||||
done();
|
||||
},
|
||||
);
|
||||
socket.connect();
|
||||
});
|
||||
|
||||
it("can send events after timeout period", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
// The "timeout period" is the period in which a timeout could potentially be triggered
|
||||
|
||||
const timeoutPeriodLength = 1_500;
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrl,
|
||||
(response) => {
|
||||
expect(response.type).toEqual("message");
|
||||
expect(response.data).toEqual("Hello world");
|
||||
socket.disconnect();
|
||||
},
|
||||
(error) => done.fail(error.message || "Unknown socket error"),
|
||||
undefined,
|
||||
() => done(),
|
||||
timeoutPeriodLength,
|
||||
);
|
||||
socket.connect();
|
||||
|
||||
setTimeout(() => socket.send("Hello world"), 2 * timeoutPeriodLength);
|
||||
});
|
||||
|
||||
it("cannot send on a disconnect socket (it will never come back)", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new SocketWrapper(
|
||||
socketServerUrl,
|
||||
() => done.fail("Got unexpected message event"),
|
||||
(error) => done.fail(error.message || "Unknown socket error"),
|
||||
() => {
|
||||
socket.disconnect();
|
||||
},
|
||||
() => {
|
||||
socket
|
||||
.send("la li lu")
|
||||
.then(() => done.fail("must not resolve"))
|
||||
.catch((error) => {
|
||||
expect(error).toMatch(/socket was closed/i);
|
||||
done();
|
||||
});
|
||||
},
|
||||
);
|
||||
socket.connect();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import WebSocket from "isomorphic-ws";
|
||||
|
||||
function environmentIsNodeJs(): boolean {
|
||||
return (
|
||||
typeof process !== "undefined" &&
|
||||
typeof process.versions !== "undefined" &&
|
||||
typeof process.versions.node !== "undefined"
|
||||
);
|
||||
}
|
||||
|
||||
export interface SocketWrapperCloseEvent {
|
||||
readonly wasClean: boolean;
|
||||
readonly code: number;
|
||||
}
|
||||
|
||||
export interface SocketWrapperErrorEvent {
|
||||
// fields available in browsers
|
||||
readonly isTrusted?: boolean;
|
||||
|
||||
// fields available in node
|
||||
readonly type?: string;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
export interface SocketWrapperMessageEvent {
|
||||
readonly data: string;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thin wrapper around isomorphic-ws' WebSocket class that adds
|
||||
* - constant message/error/open/close handlers
|
||||
* - explict connection via a connect() method
|
||||
* - type support for events
|
||||
* - handling of corner cases in the open and close behaviour
|
||||
*/
|
||||
export class SocketWrapper {
|
||||
public readonly connected: Promise<void>;
|
||||
|
||||
private connectedResolver: (() => void) | undefined;
|
||||
private connectedRejecter: ((reason: any) => void) | undefined;
|
||||
private socket: WebSocket | undefined;
|
||||
private timeoutId: NodeJS.Timeout | undefined;
|
||||
private closed = false;
|
||||
private readonly url: string;
|
||||
private readonly messageHandler: (event: SocketWrapperMessageEvent) => void;
|
||||
private readonly errorHandler: (event: SocketWrapperErrorEvent) => void;
|
||||
private readonly openHandler?: () => void;
|
||||
private readonly closeHandler?: (event: SocketWrapperCloseEvent) => void;
|
||||
private readonly timeout: number;
|
||||
|
||||
public constructor(
|
||||
url: string,
|
||||
messageHandler: (event: SocketWrapperMessageEvent) => void,
|
||||
errorHandler: (event: SocketWrapperErrorEvent) => void,
|
||||
openHandler?: () => void,
|
||||
closeHandler?: (event: SocketWrapperCloseEvent) => void,
|
||||
timeout = 10_000,
|
||||
) {
|
||||
this.connected = new Promise((resolve, reject) => {
|
||||
this.connectedResolver = resolve;
|
||||
this.connectedRejecter = reject;
|
||||
});
|
||||
|
||||
this.url = url;
|
||||
this.messageHandler = messageHandler;
|
||||
this.errorHandler = errorHandler;
|
||||
this.openHandler = openHandler;
|
||||
this.closeHandler = closeHandler;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a promise that resolves when connection is open
|
||||
*/
|
||||
public connect(): void {
|
||||
const socket = new WebSocket(this.url);
|
||||
|
||||
socket.onerror = (error) => {
|
||||
this.clearTimeout();
|
||||
if (this.errorHandler) {
|
||||
this.errorHandler(error);
|
||||
}
|
||||
};
|
||||
socket.onmessage = (messageEvent) => {
|
||||
this.messageHandler({
|
||||
type: messageEvent.type,
|
||||
data: messageEvent.data as string,
|
||||
});
|
||||
};
|
||||
socket.onopen = (_) => {
|
||||
this.clearTimeout();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.connectedResolver!();
|
||||
|
||||
if (this.openHandler) {
|
||||
this.openHandler();
|
||||
}
|
||||
};
|
||||
socket.onclose = (closeEvent) => {
|
||||
this.closed = true;
|
||||
if (this.closeHandler) {
|
||||
this.closeHandler(closeEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
this.timeoutId = setTimeout(() => {
|
||||
socket.onmessage = () => 0;
|
||||
socket.onerror = () => 0;
|
||||
socket.onopen = () => 0;
|
||||
socket.onclose = () => 0;
|
||||
socket.close();
|
||||
this.socket = undefined;
|
||||
|
||||
const elapsed = Math.floor(Date.now() - started);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.connectedRejecter!(`Connection attempt timed out after ${elapsed} ms`);
|
||||
}, this.timeout);
|
||||
|
||||
this.socket = socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes an established connection and aborts other connection states
|
||||
*/
|
||||
public disconnect(): void {
|
||||
if (!this.socket) {
|
||||
throw new Error("Socket undefined. This must be called after connecting.");
|
||||
}
|
||||
|
||||
this.clearTimeout();
|
||||
|
||||
switch (this.socket.readyState) {
|
||||
case WebSocket.OPEN:
|
||||
this.socket.close(1000 /* Normal Closure */);
|
||||
break;
|
||||
case WebSocket.CLOSED:
|
||||
// nothing to be done
|
||||
break;
|
||||
case WebSocket.CONNECTING:
|
||||
// imitate missing abort API
|
||||
this.socket.onopen = () => 0;
|
||||
this.socket.onclose = () => 0;
|
||||
this.socket.onerror = () => 0;
|
||||
this.socket.onmessage = () => 0;
|
||||
this.socket = undefined;
|
||||
if (this.closeHandler) {
|
||||
this.closeHandler({ wasClean: false, code: 4001 });
|
||||
}
|
||||
break;
|
||||
case WebSocket.CLOSING:
|
||||
// already closing. Let it proceed
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown readyState: ${this.socket.readyState}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async send(data: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket) {
|
||||
throw new Error("Socket undefined. This must be called after connecting.");
|
||||
}
|
||||
|
||||
if (this.closed) {
|
||||
throw new Error("Socket was closed, so no data can be sent anymore.");
|
||||
}
|
||||
|
||||
// this exception should be thrown by send() automatically according to
|
||||
// https://developer.mozilla.org/de/docs/Web/API/WebSocket#send() but it does not work in browsers
|
||||
if (this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("Websocket is not open");
|
||||
}
|
||||
|
||||
if (environmentIsNodeJs()) {
|
||||
this.socket.send(data, (err) => (err ? reject(err) : resolve()));
|
||||
} else {
|
||||
// Browser websocket send method does not accept a callback
|
||||
this.socket.send(data);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the timeout function, such that no timeout error will be raised anymore. This should be
|
||||
* called when the connection is established, a connection error occurred or the socket is disconnected.
|
||||
*
|
||||
* This method must not be called before `connect()`.
|
||||
* This method is idempotent.
|
||||
*/
|
||||
private clearTimeout(): void {
|
||||
if (!this.timeoutId) {
|
||||
throw new Error(
|
||||
"Timeout ID not set. This should not happen and usually means connect() was not called.",
|
||||
);
|
||||
}
|
||||
|
||||
// Note: do not unset this.timeoutId to allow multiple calls to this function
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { toListPromise } from "@iov/stream";
|
||||
|
||||
import { StreamingSocket } from "./streamingsocket";
|
||||
|
||||
function skipTests(): boolean {
|
||||
return !process.env.SOCKETSERVER_ENABLED;
|
||||
}
|
||||
|
||||
function pendingWithoutSocketServer(): void {
|
||||
if (skipTests()) {
|
||||
pending("Set SOCKETSERVER_ENABLED to enable socket tests");
|
||||
}
|
||||
}
|
||||
|
||||
describe("StreamingSocket", () => {
|
||||
const socketServerUrl = "ws://localhost:4444/websocket";
|
||||
const socketServerUrlSlow = "ws://localhost:4445/websocket";
|
||||
|
||||
it("can be constructed", () => {
|
||||
const socket = new StreamingSocket(socketServerUrl);
|
||||
expect(socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can connect", async () => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new StreamingSocket(socketServerUrl);
|
||||
expect(socket).toBeTruthy();
|
||||
socket.connect();
|
||||
await socket.connected;
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
it("can connect to slow server", async () => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new StreamingSocket(socketServerUrlSlow);
|
||||
expect(socket).toBeTruthy();
|
||||
socket.connect();
|
||||
await socket.connected;
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
it("times out when establishing connection takes too long", async () => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new StreamingSocket(socketServerUrlSlow, 2_000);
|
||||
socket.connect();
|
||||
|
||||
await socket.connected
|
||||
.then(() => fail("must not resolve"))
|
||||
.catch((error) => expect(error).toMatch(/connection attempt timed out/i));
|
||||
});
|
||||
|
||||
it("can send events when connected", async () => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new StreamingSocket(socketServerUrl);
|
||||
|
||||
const responsePromise = toListPromise(socket.events, 3);
|
||||
|
||||
socket.connect();
|
||||
await socket.connected;
|
||||
|
||||
await socket.send("aabbccdd");
|
||||
await socket.send("whatever");
|
||||
await socket.send("lalala");
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.length).toEqual(3);
|
||||
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
it("completes stream when disconnected", (done) => {
|
||||
pendingWithoutSocketServer();
|
||||
|
||||
const socket = new StreamingSocket(socketServerUrl);
|
||||
expect(socket).toBeTruthy();
|
||||
const subscription = socket.events.subscribe({
|
||||
complete: () => {
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
},
|
||||
});
|
||||
|
||||
(async () => {
|
||||
socket.connect();
|
||||
await socket.connected;
|
||||
await socket.send("aabbccdd");
|
||||
await socket.send("whatever");
|
||||
await socket.send("lalala");
|
||||
socket.disconnect();
|
||||
})().catch(done.fail);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Listener, Producer, Stream } from "xstream";
|
||||
|
||||
import { SocketWrapper, SocketWrapperMessageEvent } from "./socketwrapper";
|
||||
|
||||
/**
|
||||
* A WebSocket wrapper that exposes all events as a stream.
|
||||
*
|
||||
* This underlying socket will not be closed when the stream has no listeners
|
||||
*/
|
||||
export class StreamingSocket {
|
||||
public readonly connected: Promise<void>;
|
||||
public readonly events: Stream<SocketWrapperMessageEvent>;
|
||||
private eventProducerListener: Listener<SocketWrapperMessageEvent> | undefined;
|
||||
private readonly socket: SocketWrapper;
|
||||
|
||||
public constructor(url: string, timeout = 10_000) {
|
||||
this.socket = new SocketWrapper(
|
||||
url,
|
||||
(event) => {
|
||||
if (this.eventProducerListener) {
|
||||
this.eventProducerListener.next(event);
|
||||
}
|
||||
},
|
||||
(errorEvent) => {
|
||||
if (this.eventProducerListener) {
|
||||
this.eventProducerListener.error(errorEvent);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// socket opened
|
||||
},
|
||||
(closeEvent) => {
|
||||
if (this.eventProducerListener) {
|
||||
if (closeEvent.wasClean) {
|
||||
this.eventProducerListener.complete();
|
||||
} else {
|
||||
this.eventProducerListener.error("Socket was closed unclean");
|
||||
}
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
);
|
||||
this.connected = this.socket.connected;
|
||||
|
||||
const eventProducer: Producer<any> = {
|
||||
start: (listener) => (this.eventProducerListener = listener),
|
||||
stop: () => (this.eventProducerListener = undefined),
|
||||
};
|
||||
this.events = Stream.create(eventProducer);
|
||||
}
|
||||
|
||||
public connect(): void {
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
public async send(data: string): Promise<void> {
|
||||
return this.socket.send(data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user