mirror of
https://github.com/nvms/prsm.git
synced 2025-12-16 16:10:54 +00:00
fix: improve connection reliability and add comprehensive tests
- Make connect() methods return Promises for better async control - Remove automatic connections in constructors to prevent race conditions - Handle ECONNRESET errors gracefully during disconnection - Add comprehensive test suite covering reconnection, timeouts, and concurrency
This commit is contained in:
parent
0fa7229471
commit
20fa3707ff
Binary file not shown.
@ -15,13 +15,15 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"release": "bumpp package.json && npm publish --access public"
|
"release": "bumpp package.json && npm publish --access public",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.4.1",
|
"@types/node": "^22.4.1",
|
||||||
"bumpp": "^9.5.1",
|
"bumpp": "^9.5.1",
|
||||||
"tsup": "^8.2.4",
|
"tsup": "^8.2.4",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4",
|
||||||
|
"vitest": "^2.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,9 +9,10 @@ import { Status } from "../common/status";
|
|||||||
import { IdManager } from "../server/ids";
|
import { IdManager } from "../server/ids";
|
||||||
import { Queue } from "./queue";
|
import { Queue } from "./queue";
|
||||||
|
|
||||||
export type TokenClientOptions = tls.ConnectionOptions & net.NetConnectOpts & {
|
export type TokenClientOptions = tls.ConnectionOptions &
|
||||||
secure: boolean;
|
net.NetConnectOpts & {
|
||||||
};
|
secure: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
class TokenClient extends EventEmitter {
|
class TokenClient extends EventEmitter {
|
||||||
public options: TokenClientOptions;
|
public options: TokenClientOptions;
|
||||||
@ -23,27 +24,38 @@ class TokenClient extends EventEmitter {
|
|||||||
constructor(options: TokenClientOptions) {
|
constructor(options: TokenClientOptions) {
|
||||||
super();
|
super();
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.connect();
|
this.status = Status.OFFLINE; // Initialize status but don't connect yet
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(callback?: () => void) {
|
connect(callback?: () => void): Promise<void> {
|
||||||
if (this.status >= Status.CLOSED) {
|
if (this.status >= Status.CLOSED) {
|
||||||
return false;
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hadError = false;
|
return new Promise<void>((resolve, reject) => {
|
||||||
this.status = Status.CONNECTING;
|
this.hadError = false;
|
||||||
|
this.status = Status.CONNECTING;
|
||||||
|
|
||||||
if (this.options.secure) {
|
const onConnect = () => {
|
||||||
this.socket = tls.connect(this.options, callback);
|
if (callback) callback();
|
||||||
} else {
|
resolve();
|
||||||
this.socket = net.connect(this.options, callback);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
this.connection = null;
|
if (this.options.secure) {
|
||||||
this.applyListeners();
|
this.socket = tls.connect(this.options, onConnect);
|
||||||
|
} else {
|
||||||
|
this.socket = net.connect(this.options, onConnect);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
this.socket.once("error", (err) => {
|
||||||
|
if (this.status === Status.CONNECTING) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connection = null;
|
||||||
|
this.applyListeners();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
close(callback?: () => void) {
|
close(callback?: () => void) {
|
||||||
@ -69,7 +81,11 @@ class TokenClient extends EventEmitter {
|
|||||||
private applyListeners() {
|
private applyListeners() {
|
||||||
this.socket.on("error", (error) => {
|
this.socket.on("error", (error) => {
|
||||||
this.hadError = true;
|
this.hadError = true;
|
||||||
this.emit("error", error);
|
|
||||||
|
// Don't emit ECONNRESET errors during normal disconnection scenarios
|
||||||
|
if (error.code !== "ECONNRESET" || this.status !== Status.CLOSED) {
|
||||||
|
this.emit("error", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on("close", () => {
|
this.socket.on("close", () => {
|
||||||
@ -123,11 +139,17 @@ class QueueClient extends TokenClient {
|
|||||||
|
|
||||||
private applyEvents() {
|
private applyEvents() {
|
||||||
this.on("connect", () => {
|
this.on("connect", () => {
|
||||||
while (!this.queue.isEmpty) {
|
this.processQueue();
|
||||||
const item = this.queue.pop();
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private processQueue() {
|
||||||
|
while (!this.queue.isEmpty) {
|
||||||
|
const item = this.queue.pop();
|
||||||
|
if (item) {
|
||||||
this.sendBuffer(item.value, item.expiresIn);
|
this.sendBuffer(item.value, item.expiresIn);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
@ -136,9 +158,9 @@ class QueueClient extends TokenClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CommandClient extends QueueClient {
|
export class CommandClient extends QueueClient {
|
||||||
private ids = new IdManager(0xFFFF);
|
private ids = new IdManager(0xffff);
|
||||||
private callbacks: {
|
private callbacks: {
|
||||||
[id: number]: (error: Error | null, result?: any) => void
|
[id: number]: (result: any, error?: Error) => void;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
constructor(options: TokenClientOptions) {
|
constructor(options: TokenClientOptions) {
|
||||||
@ -154,9 +176,9 @@ export class CommandClient extends QueueClient {
|
|||||||
if (this.callbacks[data.id]) {
|
if (this.callbacks[data.id]) {
|
||||||
if (data.command === 255) {
|
if (data.command === 255) {
|
||||||
const error = ErrorSerializer.deserialize(data.payload);
|
const error = ErrorSerializer.deserialize(data.payload);
|
||||||
this.callbacks[data.id](error, undefined);
|
this.callbacks[data.id](undefined, error);
|
||||||
} else {
|
} else {
|
||||||
this.callbacks[data.id](null, data.payload);
|
this.callbacks[data.id](data.payload, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -165,13 +187,39 @@ export class CommandClient extends QueueClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async command(command: number, payload: any, expiresIn: number = 30_000, callback: (result: any, error: CodeError | Error | null) => void | undefined = undefined) {
|
async command(
|
||||||
|
command: number,
|
||||||
|
payload: any,
|
||||||
|
expiresIn: number = 30_000,
|
||||||
|
callback: (
|
||||||
|
result: any,
|
||||||
|
error: CodeError | Error | null,
|
||||||
|
) => void | undefined = undefined,
|
||||||
|
) {
|
||||||
if (command === 255) {
|
if (command === 255) {
|
||||||
throw new CodeError("Command 255 is reserved.", "ERESERVED", "CommandError");
|
throw new CodeError(
|
||||||
|
"Command 255 is reserved.",
|
||||||
|
"ERESERVED",
|
||||||
|
"CommandError",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we're connected before sending commands
|
||||||
|
if (this.status < Status.ONLINE) {
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
} catch (err) {
|
||||||
|
if (typeof callback === "function") {
|
||||||
|
callback(undefined, err as Error);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = this.ids.reserve();
|
const id = this.ids.reserve();
|
||||||
const buffer = Command.toBuffer({ id, command, payload })
|
const buffer = Command.toBuffer({ id, command, payload });
|
||||||
|
|
||||||
this.sendBuffer(buffer, expiresIn);
|
this.sendBuffer(buffer, expiresIn);
|
||||||
|
|
||||||
@ -189,10 +237,18 @@ export class CommandClient extends QueueClient {
|
|||||||
const ret = await Promise.race([response, timeout]);
|
const ret = await Promise.race([response, timeout]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
callback(ret, undefined);
|
if (ret.error) {
|
||||||
} catch (callbackError) { /* */ }
|
callback(undefined, ret.error);
|
||||||
} catch (error) {
|
} else {
|
||||||
callback(undefined, error);
|
callback(ret.result, undefined);
|
||||||
|
}
|
||||||
|
// callback(ret, undefined);
|
||||||
|
} catch (callbackError) {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { result: any; error: any };
|
||||||
|
callback(undefined, err.error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Promise.race([response, timeout]);
|
return Promise.race([response, timeout]);
|
||||||
@ -200,27 +256,34 @@ export class CommandClient extends QueueClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createTimeoutPromise(id: number, expiresIn: number) {
|
private createTimeoutPromise(id: number, expiresIn: number) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<{ error: any; result: any }>((_, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.ids.release(id);
|
this.ids.release(id);
|
||||||
delete this.callbacks[id];
|
delete this.callbacks[id];
|
||||||
reject(new CodeError("Command timed out.", "ETIMEOUT", "CommandError"));
|
reject({
|
||||||
|
error: new CodeError(
|
||||||
|
"Command timed out.",
|
||||||
|
"ETIMEOUT",
|
||||||
|
"CommandError",
|
||||||
|
),
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
}, expiresIn);
|
}, expiresIn);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createResponsePromise(id: number) {
|
private createResponsePromise(id: number) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<{ error: any; result: any }>((resolve, reject) => {
|
||||||
this.callbacks[id] = (error: Error | null, result?: any) => {
|
this.callbacks[id] = (result: any, error?: Error) => {
|
||||||
this.ids.release(id);
|
this.ids.release(id);
|
||||||
delete this.callbacks[id];
|
delete this.callbacks[id];
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(error);
|
reject({ error, result: null });
|
||||||
} else {
|
} else {
|
||||||
resolve(result);
|
resolve({ result, error: null });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,18 +10,23 @@ const client = new CommandClient({
|
|||||||
const payload = { things: "stuff", numbers: [1, 2, 3] };
|
const payload = { things: "stuff", numbers: [1, 2, 3] };
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const callback = (result: any, error: CodeError) => {
|
try {
|
||||||
if (error) {
|
await client.connect();
|
||||||
console.log("ERR [0]", error.code);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("RECV [0]", result);
|
const callback = (result: any, error: CodeError) => {
|
||||||
client.close();
|
if (error) {
|
||||||
};
|
console.log("ERR [0]", error.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
client.command(0, payload, 10, callback);
|
console.log("RECV [0]", result);
|
||||||
|
client.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
client.command(0, payload, 10, callback);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Connection error:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
@ -8,6 +8,11 @@ const server = new CommandServer({
|
|||||||
secure: false,
|
secure: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.connect().catch((err) => {
|
||||||
|
console.error("Failed to start server:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
server.command(0, async (payload: any, connection: Connection) => {
|
server.command(0, async (payload: any, connection: Connection) => {
|
||||||
console.log("RECV [0]:", payload);
|
console.log("RECV [0]:", payload);
|
||||||
return { ok: "OK" };
|
return { ok: "OK" };
|
||||||
|
|||||||
@ -7,9 +7,11 @@ import { Connection } from "../common/connection";
|
|||||||
import { ErrorSerializer } from "../common/errorserializer";
|
import { ErrorSerializer } from "../common/errorserializer";
|
||||||
import { Status } from "../common/status";
|
import { Status } from "../common/status";
|
||||||
|
|
||||||
export type TokenServerOptions = tls.TlsOptions & net.ListenOptions & net.SocketConstructorOpts & {
|
export type TokenServerOptions = tls.TlsOptions &
|
||||||
secure?: boolean;
|
net.ListenOptions &
|
||||||
};
|
net.SocketConstructorOpts & {
|
||||||
|
secure?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export class TokenServer extends EventEmitter {
|
export class TokenServer extends EventEmitter {
|
||||||
connections: Connection[] = [];
|
connections: Connection[] = [];
|
||||||
@ -24,13 +26,14 @@ export class TokenServer extends EventEmitter {
|
|||||||
super();
|
super();
|
||||||
|
|
||||||
this.options = options;
|
this.options = options;
|
||||||
|
this.status = Status.OFFLINE;
|
||||||
|
|
||||||
if (this.options.secure) {
|
if (this.options.secure) {
|
||||||
this.server = tls.createServer(this.options, function (clientSocket) {
|
this.server = tls.createServer(this.options, function (clientSocket) {
|
||||||
clientSocket.on("error", (err) => {
|
clientSocket.on("error", (err) => {
|
||||||
this.emit("clientError", err);
|
this.emit("clientError", err);
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
this.server = net.createServer(this.options, function (clientSocket) {
|
this.server = net.createServer(this.options, function (clientSocket) {
|
||||||
clientSocket.on("error", (err) => {
|
clientSocket.on("error", (err) => {
|
||||||
@ -40,18 +43,24 @@ export class TokenServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.applyListeners();
|
this.applyListeners();
|
||||||
this.connect();
|
// Don't automatically connect in constructor
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(callback?: () => void) {
|
connect(callback?: () => void): Promise<void> {
|
||||||
if (this.status >= Status.CONNECTING) return false;
|
if (this.status >= Status.CONNECTING) return Promise.resolve();
|
||||||
|
|
||||||
this.hadError = false;
|
this.hadError = false;
|
||||||
this.status = Status.CONNECTING;
|
this.status = Status.CONNECTING;
|
||||||
this.server.listen(this.options, () => {
|
|
||||||
if (callback) callback();
|
return new Promise<void>((resolve) => {
|
||||||
|
this.server.listen(this.options, () => {
|
||||||
|
// Wait a small tick to ensure the server socket is fully bound
|
||||||
|
setImmediate(() => {
|
||||||
|
if (callback) callback();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close(callback?: () => void) {
|
close(callback?: () => void) {
|
||||||
@ -129,7 +138,7 @@ type CommandFn = (payload: any, connection: Connection) => Promise<any>;
|
|||||||
|
|
||||||
export class CommandServer extends TokenServer {
|
export class CommandServer extends TokenServer {
|
||||||
private commands: {
|
private commands: {
|
||||||
[command: number]: CommandFn
|
[command: number]: CommandFn;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
constructor(options: TokenServerOptions) {
|
constructor(options: TokenServerOptions) {
|
||||||
@ -157,10 +166,26 @@ export class CommandServer extends TokenServer {
|
|||||||
this.commands[command] = fn;
|
this.commands[command] = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runCommand(id: number, command: number, payload: any, connection: Connection) {
|
private async runCommand(
|
||||||
|
id: number,
|
||||||
|
command: number,
|
||||||
|
payload: any,
|
||||||
|
connection: Connection,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (!this.commands[command]) {
|
if (!this.commands[command]) {
|
||||||
throw new CodeError(`Command (${command}) not found.`, "ENOTFOUND", "CommandError");
|
connection.send(
|
||||||
|
Command.toBuffer({
|
||||||
|
command: 255,
|
||||||
|
id,
|
||||||
|
payload: new CodeError(
|
||||||
|
`Command (${command}) not found.`,
|
||||||
|
"ENOTFOUND",
|
||||||
|
"CommandError",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.commands[command](payload, connection);
|
const result = await this.commands[command](payload, connection);
|
||||||
@ -169,7 +194,9 @@ export class CommandServer extends TokenServer {
|
|||||||
// we respond with a simple "OK".
|
// we respond with a simple "OK".
|
||||||
const payloadResult = result === undefined ? "OK" : result;
|
const payloadResult = result === undefined ? "OK" : result;
|
||||||
|
|
||||||
connection.send(Command.toBuffer({ command, id, payload: payloadResult }));
|
connection.send(
|
||||||
|
Command.toBuffer({ command, id, payload: payloadResult }),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const payload = ErrorSerializer.serialize(error);
|
const payload = ErrorSerializer.serialize(error);
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,9 @@ export class IdManager {
|
|||||||
|
|
||||||
release(id: number) {
|
release(id: number) {
|
||||||
if (id < 0 || id > this.maxIndex) {
|
if (id < 0 || id > this.maxIndex) {
|
||||||
throw new TypeError(`ID must be between 0 and ${this.maxIndex}. Got ${id}.`);
|
throw new TypeError(
|
||||||
|
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.ids[id] = false;
|
this.ids[id] = false;
|
||||||
}
|
}
|
||||||
@ -33,7 +35,9 @@ export class IdManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.index === startIndex) {
|
if (this.index === startIndex) {
|
||||||
throw new Error(`All IDs are reserved. Make sure to release IDs when they are no longer used.`);
|
throw new Error(
|
||||||
|
`All IDs are reserved. Make sure to release IDs when they are no longer used.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
282
packages/duplex/tests/advanced.test.ts
Normal file
282
packages/duplex/tests/advanced.test.ts
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { CommandClient, CommandServer, Status } from "../src/index";
|
||||||
|
|
||||||
|
describe("Advanced CommandClient and CommandServer Tests", () => {
|
||||||
|
const serverOptions = { host: "localhost", port: 8125, secure: false };
|
||||||
|
const clientOptions = { host: "localhost", port: 8125, secure: false };
|
||||||
|
let server: CommandServer;
|
||||||
|
let client: CommandClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server = new CommandServer(serverOptions);
|
||||||
|
server.command(100, async (payload) => {
|
||||||
|
return `Echo: ${payload}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
client = new CommandClient(clientOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (client.status === Status.ONLINE) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
client.once("close", () => resolve());
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.status === Status.ONLINE) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.once("close", () => resolve());
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client reconnects after server restart", async () => {
|
||||||
|
await server.connect();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Verify initial connection
|
||||||
|
expect(client.status).toBe(Status.ONLINE);
|
||||||
|
|
||||||
|
// First close the client gracefully
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
client.once("close", () => resolve());
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then close the server
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.once("close", () => resolve());
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart server
|
||||||
|
await server.connect();
|
||||||
|
|
||||||
|
// Reconnect client
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Verify reconnection worked
|
||||||
|
expect(client.status).toBe(Status.ONLINE);
|
||||||
|
|
||||||
|
// Verify functionality after reconnection
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
client.command(100, "After Reconnect", 5000, (result, error) => {
|
||||||
|
try {
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
expect(result).toBe("Echo: After Reconnect");
|
||||||
|
resolve();
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
test("command times out when server doesn't respond", async () => {
|
||||||
|
await server.connect();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// A command that never responds
|
||||||
|
server.command(101, async () => {
|
||||||
|
return new Promise(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expect it to fail after a short timeout
|
||||||
|
await expect(
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
client.command(101, "Should timeout", 500, (result, error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).rejects.toHaveProperty("code", "ETIMEOUT");
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
test("server errors are properly serialized to client", async () => {
|
||||||
|
await server.connect();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
server.command(102, async () => {
|
||||||
|
const error = new Error("Custom server error") as any;
|
||||||
|
error.code = "ECUSTOM";
|
||||||
|
error.name = "CustomError";
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expect to receive this error
|
||||||
|
await expect(
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
client.command(102, "Will error", 1000, (result, error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
message: "Custom server error",
|
||||||
|
name: "CustomError",
|
||||||
|
code: "ECUSTOM",
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
test("commands are queued when client is offline and sent when reconnected", async () => {
|
||||||
|
// Start with server but no client connection
|
||||||
|
await server.connect();
|
||||||
|
|
||||||
|
// Create client but don't connect yet
|
||||||
|
const queuedClient = new CommandClient(clientOptions);
|
||||||
|
|
||||||
|
// Queue a command while offline
|
||||||
|
const commandPromise = new Promise((resolve, reject) => {
|
||||||
|
queuedClient.command(100, "Queued Message", 5000, (result, error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now connect the client - the queued command should be sent
|
||||||
|
await queuedClient.connect();
|
||||||
|
|
||||||
|
// Verify the queued command was processed
|
||||||
|
await expect(commandPromise).resolves.toBe("Echo: Queued Message");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
queuedClient.once("close", () => resolve());
|
||||||
|
queuedClient.close();
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
test("multiple concurrent commands are handled correctly", async () => {
|
||||||
|
await server.connect();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Register commands with different delays
|
||||||
|
server.command(103, async (payload) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
return `Fast: ${payload}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
server.command(104, async (payload) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
return `Slow: ${payload}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send multiple commands concurrently
|
||||||
|
const results = await Promise.all([
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
client.command(103, "First", 1000, (result, error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
client.command(104, "Second", 1000, (result, error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
client.command(100, "Third", 1000, (result, error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify all commands completed successfully
|
||||||
|
expect(results).toEqual(["Fast: First", "Slow: Second", "Echo: Third"]);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
test("handles large payloads correctly", async () => {
|
||||||
|
await server.connect();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const largeData = {
|
||||||
|
array: Array(1000)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => `item-${i}`),
|
||||||
|
nested: {
|
||||||
|
deep: {
|
||||||
|
object: {
|
||||||
|
with: "lots of data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
client.command(100, largeData, 5000, (result, error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the response contains the expected prefix
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect((result as string).startsWith("Echo: ")).toBe(true);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
test("server handles multiple client connections", async () => {
|
||||||
|
await server.connect();
|
||||||
|
|
||||||
|
// Create multiple clients
|
||||||
|
const clients = Array(5)
|
||||||
|
.fill(0)
|
||||||
|
.map(() => new CommandClient(clientOptions));
|
||||||
|
|
||||||
|
// Connect all clients
|
||||||
|
await Promise.all(clients.map((client) => client.connect()));
|
||||||
|
|
||||||
|
// Send a command from each client
|
||||||
|
const results = await Promise.all(
|
||||||
|
clients.map(
|
||||||
|
(client, i) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
client.command(100, `Client ${i}`, 1000, (result, error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify all commands succeeded
|
||||||
|
results.forEach((result, i) => {
|
||||||
|
expect(result).toBe(`Echo: Client ${i}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await Promise.all(
|
||||||
|
clients.map(
|
||||||
|
(client) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
client.once("close", () => resolve());
|
||||||
|
client.close();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
test("command returns promise when no callback provided", async () => {
|
||||||
|
await server.connect();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Use the promise-based API
|
||||||
|
const result = await client.command(100, "Promise API");
|
||||||
|
|
||||||
|
// Verify the result
|
||||||
|
expect(result).toHaveProperty("result", "Echo: Promise API");
|
||||||
|
expect(result).toHaveProperty("error", null);
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { CommandClient, CommandServer } from "../src/index";
|
import { CommandClient, CommandServer } from "../src/index";
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
describe("CommandClient and CommandServer", () => {
|
describe("CommandClient and CommandServer", () => {
|
||||||
const serverOptions = { host: "localhost", port: 8124, secure: false };
|
const serverOptions = { host: "localhost", port: 8124, secure: false };
|
||||||
const clientOptions = { host: "localhost", port: 8124, secure: false };
|
const clientOptions = { host: "localhost", port: 8124, secure: false };
|
||||||
@ -18,100 +16,49 @@ describe("CommandClient and CommandServer", () => {
|
|||||||
client = new CommandClient(clientOptions);
|
client = new CommandClient(clientOptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
if (client.status === 3) { // ONLINE
|
if (client.status === 3) {
|
||||||
client.close();
|
// ONLINE
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
client.once("close", () => resolve());
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (server.status === 3) { // ONLINE
|
|
||||||
server.close();
|
if (server.status === 3) {
|
||||||
|
// ONLINE
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.once("close", () => resolve());
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("client-server connection should be online", async () => {
|
test("client-server connection should be online", async () => {
|
||||||
await new Promise<void>((resolve) => {
|
await server.connect();
|
||||||
server.once("listening", () => {
|
await client.connect();
|
||||||
client.once("connect", () => {
|
expect(client.status).toBe(3); // ONLINE
|
||||||
expect(client.status).toBe(3); // ONLINE
|
}, 1000);
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
server.connect();
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
test("simple echo command", async () => {
|
test("simple echo command", async () => {
|
||||||
await new Promise<void>((resolve) => {
|
try {
|
||||||
server.once("listening", () => {
|
await server.connect();
|
||||||
client.once("connect", () => {
|
|
||||||
client.command(100, "Hello", 5000, (result, error) => {
|
await client.connect();
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
client.command(100, "Hello", 5000, (result, error) => {
|
||||||
|
try {
|
||||||
expect(error).toBeUndefined();
|
expect(error).toBeUndefined();
|
||||||
expect(result).toBe("Echo: Hello");
|
expect(result).toBe("Echo: Hello");
|
||||||
resolve();
|
resolve();
|
||||||
});
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
server.connect();
|
} catch (err) {
|
||||||
});
|
throw err;
|
||||||
}, 5000);
|
}
|
||||||
|
}, 1000);
|
||||||
// test("handle unknown command", async () => {
|
|
||||||
// await sleep(1000);
|
|
||||||
// await new Promise<void>((resolve) => {
|
|
||||||
// server.once("listening", () => {
|
|
||||||
// console.log("Listening! (unknown command)");
|
|
||||||
// client.once("connect", () => {
|
|
||||||
// console.log("Client connected, sending command.");
|
|
||||||
// client.command(55, "Hello", 1000, (result, error) => {
|
|
||||||
// console.log("Client callback CALLED! with result", result, "and error", error);
|
|
||||||
// expect(result).toBeUndefined();
|
|
||||||
// // expect(error).toBeDefined();
|
|
||||||
// // expect(error.code).toBe("ENOTFOUND");
|
|
||||||
// resolve();
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
// server.connect();
|
|
||||||
// });
|
|
||||||
// }, 2000); // Increased timeout
|
|
||||||
|
|
||||||
// test("command should timeout without server response", async () => {
|
|
||||||
// await new Promise<void>((resolve) => {
|
|
||||||
// server.once("listening", () => {
|
|
||||||
// client.once("connect", () => {
|
|
||||||
// client.command(101, "No response", 1000, (result, error) => {
|
|
||||||
// expect(result).toBeUndefined();
|
|
||||||
// expect(error).toBeInstanceOf(CodeError);
|
|
||||||
// expect(error.code).toBe("ETIMEOUT");
|
|
||||||
// resolve();
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
// server.connect();
|
|
||||||
// });
|
|
||||||
// }, 10000); // Increased timeout
|
|
||||||
|
|
||||||
// test("client should handle server close event", async () => {
|
|
||||||
// await new Promise<void>((resolve) => {
|
|
||||||
// let errorEmitted = false;
|
|
||||||
// client.once("error", () => {
|
|
||||||
// errorEmitted = true;
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// client.once("close", () => {
|
|
||||||
// expect(errorEmitted).toBe(false);
|
|
||||||
// expect(client.status).toBe(0); // OFFLINE
|
|
||||||
// resolve();
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// server.once("listening", () => {
|
|
||||||
// client.once("connect", () => {
|
|
||||||
// server.close(() => {
|
|
||||||
// setTimeout(() => client.close(), 200);
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// server.connect();
|
|
||||||
// });
|
|
||||||
// }, 10000); // Increased timeout
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user