mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 23:24:52 +09:00
Reverse the direction of the API for sending messages from
messageDefinition.send(link, { content });
to
link.send(new MessageClass(content));
and convert all message definitions to use the JSON serialisable class
pattern and explicit registration of message handlers, along with a
end-to-end based routing of messages instead of link to link.
The experience I gained from the existing way messages were defined and
sent over links was that it was difficult to understand for people
unfamiliar with the system. The automatic registration of message
handlers ment that code was being called as if by magic from nowhere
with no easy way to discover it. The manual forwarding and rules for
what can be sent where and how obscured its working.
The use of pure JSON as both input and output from message handlers made
it tedious to work with. Data had to be manually converted between the
program representation and the over the wire format, which made handler
code unplesant to write. Particularly because the over the wire format
used snake_case while the program code uses camelCase for variables.
The idea behind the decision to do it this way was to make it more clear
which data came from the wire and which from the program, and while this
worked it didn't make message handlers any nicer to write.
The direction of the API and magic registration of message handlers also
meant that a future migration to TypeScript would be troublesome. While
it may be possible to implement, the degree of TypeScript magic required
would go starkly against the principle of keep it simple stupid.
By refactoring to using the JSON serialisable class pattern most of
these issues go away. There is no longer a need to distinguish the on
wire format of messages with the program representation because the only
place that deals with the wire format of messages are the toJSON and
fromJSON methods on the classes themselves. Message handlers will work
with proper instances of classes instead of raw data, something that
makes them easier to write, and trivial to type with TypeScript.
End-to-end routing also simplifies sending messages that need to do
mulitple hops to reach the destination. And removes the need for having
all messages be specified in plugin's info module, as they can be routed
without having to be parsed completely.
The explicit registration of message handlers is something that I
thought would be tedious boilerplate code that could be automated away.
But experience now shows it to be the opposite. Allthough dull
repetition, the purpose it serves is to link together the code in a way
that humans can easily find. With explicit registration searching the
name of the message handler in the editor will show the registration that
ties it together with the type of message it handles and the kind of
registration. With implicit registration searching the name of the
message handler turns up only the handler with no clue as to what calls
it.
403 lines
15 KiB
JavaScript
403 lines
15 KiB
JavaScript
"use strict";
|
|
const assert = require("assert").strict;
|
|
const events = require("events");
|
|
|
|
const libData = require("@clusterio/lib/data");
|
|
const libLink = require("@clusterio/lib/link");
|
|
const libErrors = require("@clusterio/lib/errors");
|
|
const mock = require("../../mock");
|
|
|
|
const addr = libData.Address.fromShorthand;
|
|
|
|
describe("lib/link/link", function() {
|
|
function throwSimple(message) {
|
|
let err = new Error(message);
|
|
err.stack = message;
|
|
throw err;
|
|
}
|
|
|
|
describe("class Link", function() {
|
|
let testConnector;
|
|
let testLink;
|
|
let src = addr({ controlId: 1 });
|
|
let dst = addr("controller");
|
|
|
|
class SimpleRequest {
|
|
static type = "request";
|
|
static src = ["controller", "control"];
|
|
static dst = ["controller", "control"];
|
|
}
|
|
class NumberRequest {
|
|
static type = "request";
|
|
static src = ["controller", "control"];
|
|
static dst = ["controller", "control"];
|
|
constructor(value) { this.value = value; }
|
|
static jsonSchema = { type: "number" };
|
|
toJSON() { return this.value; }
|
|
static fromJSON(json) { return new this(json); }
|
|
}
|
|
NumberRequest.Response = class {
|
|
constructor(value) { this.value = value; }
|
|
static jsonSchema = { type: "number" };
|
|
toJSON() { return this.value; }
|
|
static fromJSON(json) { return new this(json); }
|
|
};
|
|
class SimpleEvent {
|
|
static type = "event";
|
|
static src = ["controller", "control"];
|
|
static dst = ["controller", "control"];
|
|
}
|
|
class NumberEvent {
|
|
static type = "event";
|
|
static src = ["controller", "control"];
|
|
static dst = ["controller", "control"];
|
|
constructor(value) { this.value = value; }
|
|
static jsonSchema = { type: "number" };
|
|
toJSON() { return this.value; }
|
|
static fromJSON(json) { return new this(json); }
|
|
}
|
|
|
|
beforeEach(function() {
|
|
testConnector = new mock.MockConnector(src, dst);
|
|
testLink = new libLink.Link(testConnector);
|
|
});
|
|
|
|
it("should handle unknown message", async function() {
|
|
testConnector.emit("message", { type: "unknown" });
|
|
});
|
|
it("should give an error response back on unrecognized request", function() {
|
|
testConnector.emit("message", new libData.MessageRequest(1, dst, src, "UnhandledRequest"));
|
|
assert.deepEqual(testConnector.sentMessages, [
|
|
new libData.MessageResponseError(
|
|
testConnector._seq - 1,
|
|
src,
|
|
dst,
|
|
new libData.ResponseError("Unrecognized request UnhandledRequest")
|
|
),
|
|
]);
|
|
});
|
|
it("should give an error response back on unhandled request", function() {
|
|
class UnhandledRequest {
|
|
static type = "request";
|
|
static src = "controller";
|
|
static dst = "control";
|
|
}
|
|
testLink.register(UnhandledRequest);
|
|
testConnector.emit("message", new libData.MessageRequest(1, dst, src, "UnhandledRequest"));
|
|
assert.deepEqual(testConnector.sentMessages, [
|
|
new libData.MessageResponseError(
|
|
testConnector._seq - 1,
|
|
src,
|
|
dst,
|
|
new libData.ResponseError("No handler registered for UnhandledRequest")
|
|
),
|
|
]);
|
|
});
|
|
|
|
it("should send ready on connector prepareDisconnect", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testConnector.emit("disconnectPrepare");
|
|
assert.deepEqual(await message, [new libData.MessageDisconnect("ready")]);
|
|
});
|
|
it("should send ready on connector prepareDisconnect if an error occurs", async function() {
|
|
testLink.prepareDisconnect = async () => { throwSimple("Error occured"); };
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testConnector.emit("disconnectPrepare");
|
|
assert.deepEqual(await message, [new libData.MessageDisconnect("ready")]);
|
|
});
|
|
|
|
it("should reject pending requests on close", async function() {
|
|
let pending = testLink.send(new SimpleRequest());
|
|
pending.catch(() => {});
|
|
testConnector.emit("close");
|
|
await assert.rejects(pending, { message: "Session Closed" });
|
|
});
|
|
it("should reject pending requests on invalidate", async function() {
|
|
let pending = testLink.send(new SimpleRequest());
|
|
pending.catch(() => {});
|
|
testConnector.emit("invalidate");
|
|
await assert.rejects(pending, { message: "Session Lost" });
|
|
});
|
|
|
|
describe(".send()", function() {
|
|
it("should send request to the other side of the link", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testLink.send(new SimpleRequest());
|
|
let srcReq = new libData.Address(libData.Address.control, 1, 1);
|
|
assert.deepEqual(
|
|
await message,
|
|
[new libData.MessageRequest(1, srcReq, dst, "SimpleRequest", undefined)]
|
|
);
|
|
});
|
|
it("should send request with data to the other side of the link", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testLink.send(new NumberRequest(22));
|
|
let srcReq = new libData.Address(libData.Address.control, 1, 1);
|
|
assert.deepEqual(
|
|
await message,
|
|
[new libData.MessageRequest(1, srcReq, dst, "NumberRequest", new NumberRequest(22))]
|
|
);
|
|
});
|
|
it("should send request and resolve when response is received", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
let request = testLink.send(new SimpleRequest());
|
|
let srcReq = new libData.Address(libData.Address.control, 1, 1);
|
|
testConnector.emit("message", new libData.MessageResponse(1, dst, srcReq));
|
|
await request;
|
|
});
|
|
it("should send request and reject with error when error response is received", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
let request = testLink.send(new SimpleRequest());
|
|
let srcReq = new libData.Address(libData.Address.control, 1, 1);
|
|
testConnector.emit(
|
|
"message", new libData.MessageResponseError(1, dst, srcReq, new libData.ResponseError("Error"))
|
|
);
|
|
await assert.rejects(
|
|
request,
|
|
{ message: "Error" }
|
|
);
|
|
});
|
|
it("should send request and resolve with data when response is received", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
let request = testLink.send(new NumberRequest(22));
|
|
let srcReq = new libData.Address(libData.Address.control, 1, 1);
|
|
testConnector.emit("message", new libData.MessageResponse(1, dst, srcReq, 44));
|
|
assert.deepEqual(await request, new NumberRequest.Response(44));
|
|
});
|
|
it("should send event to the other side of the link", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testLink.send(new SimpleEvent());
|
|
assert.deepEqual(
|
|
await message,
|
|
[new libData.MessageEvent(1, src, dst, "SimpleEvent", undefined)]
|
|
);
|
|
});
|
|
it("should send event with data to the other side of the link", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testLink.send(new NumberEvent(22));
|
|
assert.deepEqual(
|
|
await message,
|
|
[new libData.MessageEvent(1, src, dst, "NumberEvent", new NumberEvent(22))]
|
|
);
|
|
});
|
|
});
|
|
describe(".register()", function() {
|
|
it("should register a request", function() {
|
|
let handled = false;
|
|
testLink.register(SimpleRequest, async () => { handled = true; });
|
|
assert(testLink._registeredRequests.has("SimpleRequest"), "request was not registered");
|
|
testConnector.emit("message", new libData.MessageRequest(1, dst, src, "SimpleRequest"));
|
|
assert(handled, "request was not handled");
|
|
});
|
|
it("should send response error from request handler throwing", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testLink.register(SimpleRequest, async () => { throwSimple("Error"); });
|
|
testConnector.emit("message", new libData.MessageRequest(1, dst, src, "SimpleRequest"));
|
|
assert.deepEqual(
|
|
await message,
|
|
[new libData.MessageResponseError(
|
|
1, src, dst, new libData.ResponseError("Error", undefined, "Error")
|
|
)]
|
|
);
|
|
});
|
|
it("should send response error on request validation failing", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testLink.register(NumberRequest, async () => 1);
|
|
testConnector.emit("message", new libData.MessageRequest(1, dst, src, "NumberRequest", "not a number"));
|
|
let response = (await message)[0];
|
|
assert.deepEqual(
|
|
response,
|
|
new libData.MessageResponseError(1, src, dst, new libData.ResponseError(
|
|
"Request NumberRequest failed validation",
|
|
response.data.code,
|
|
response.data.stack,
|
|
))
|
|
);
|
|
});
|
|
it("should send response error on response validation failing", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testLink.register(NumberRequest, async () => "not a number");
|
|
testConnector.emit("message", new libData.MessageRequest(1, dst, src, "NumberRequest", 1));
|
|
let response = (await message)[0];
|
|
assert.deepEqual(
|
|
response,
|
|
new libData.MessageResponseError(1, src, dst, new libData.ResponseError(
|
|
"Response for request NumberRequest failed validation",
|
|
response.data.code,
|
|
response.data.stack,
|
|
))
|
|
);
|
|
});
|
|
it("should send value returned from request handler", async function() {
|
|
let message = events.once(testConnector, "send");
|
|
message.catch(() => {});
|
|
testLink.register(NumberRequest, async (request) => request.value + 4);
|
|
testConnector.emit("message", new libData.MessageRequest(1, dst, src, "NumberRequest", 1));
|
|
let response = (await message)[0];
|
|
assert.deepEqual(
|
|
response,
|
|
new libData.MessageResponse(1, src, dst, 5)
|
|
);
|
|
});
|
|
it("should register an event", function() {
|
|
let handled = false;
|
|
testLink.register(SimpleEvent, async () => { handled = true; });
|
|
assert(testLink._registeredEvents.has("SimpleEvent"), "event was not registered");
|
|
testConnector.emit("message", new libData.MessageEvent(1, dst, src, "SimpleEvent"));
|
|
assert(handled, "event was not handled");
|
|
});
|
|
it("should pass value to event handler", async function() {
|
|
let value;
|
|
testLink.register(NumberEvent, async (event) => { value = event.value; });
|
|
testConnector.emit("message", new libData.MessageEvent(1, dst, src, "NumberEvent", 9));
|
|
assert.deepEqual(value, 9);
|
|
});
|
|
it("should log errors from event handler", function() {
|
|
testLink.register(SimpleEvent, async () => { throwSimple("Error"); });
|
|
testConnector.emit("message", new libData.MessageEvent(1, dst, src, "SimpleEvent"));
|
|
});
|
|
it("should throw on unknown type", function() {
|
|
assert.throws(
|
|
() => testLink.register({ name: "Bad", type: "bad" }),
|
|
{ message: "Class Bad has unrecognized type bad" }
|
|
);
|
|
});
|
|
it("should throw on double registration", function() {
|
|
testLink.register(SimpleRequest);
|
|
assert.throws(
|
|
() => testLink.register(SimpleRequest),
|
|
new Error("Request SimpleRequest is already registered")
|
|
);
|
|
testLink.register(SimpleEvent);
|
|
assert.throws(
|
|
() => testLink.register(SimpleEvent),
|
|
new Error("Event SimpleEvent is already registered")
|
|
);
|
|
});
|
|
it("should throw if Request has only one of jsonSchema and fromJSON", function() {
|
|
class BadRequest1 {
|
|
static type = "request";
|
|
static src = "controller";
|
|
static dst = "control";
|
|
static jsonSchema = {};
|
|
}
|
|
assert.throws(
|
|
() => testLink.register(BadRequest1),
|
|
new Error("Request BadRequest1 has static jsonSchema but is missing static fromJSON")
|
|
);
|
|
class BadRequest2 {
|
|
static type = "request";
|
|
static src = "controller";
|
|
static dst = "control";
|
|
static fromJSON() {};
|
|
}
|
|
assert.throws(
|
|
() => testLink.register(BadRequest2),
|
|
new Error("Request BadRequest2 has static fromJSON but is missing static jsonSchema")
|
|
);
|
|
});
|
|
it("should throw if Event has only one of jsonSchema and fromJSON", function() {
|
|
class BadEvent1 {
|
|
static type = "event";
|
|
static src = "controller";
|
|
static dst = "control";
|
|
static jsonSchema = {};
|
|
}
|
|
assert.throws(
|
|
() => testLink.register(BadEvent1),
|
|
new Error("Event BadEvent1 has static jsonSchema but is missing static fromJSON")
|
|
);
|
|
class BadEvent2 {
|
|
static type = "event";
|
|
static src = "controller";
|
|
static dst = "control";
|
|
static fromJSON() {};
|
|
}
|
|
assert.throws(
|
|
() => testLink.register(BadEvent2),
|
|
new Error("Event BadEvent2 has static fromJSON but is missing static jsonSchema")
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("._processMessage()", function() {
|
|
it("should throw on unhandled type", function() {
|
|
assert.throws(
|
|
() => testLink._processMessage({ type: "invalid" }),
|
|
{ message: "Unhandled message type invalid" }
|
|
);
|
|
});
|
|
it("should throw on Event failing validation", function() {
|
|
class StringEvent {
|
|
static type = "event";
|
|
static src = "controller";
|
|
static dst = "control";
|
|
constructor(value) { this.value = value; }
|
|
static jsonSchema = { type: "string" };
|
|
static fromJSON(json) { return new this(json); };
|
|
}
|
|
testLink.register(StringEvent, () => {});
|
|
assert.throws(
|
|
() => testLink._processMessage(new libData.MessageEvent(1, dst, src, "StringEvent", 99)),
|
|
{ message: "Event StringEvent failed validation" }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe(".snoopEvent()", function() {
|
|
it("should snoop an event", function() {
|
|
let handled = false;
|
|
testLink.snoopEvent(SimpleEvent, async () => { handled = true; });
|
|
assert(testLink._snoopedEvents.has("SimpleEvent"), "event was not snooped");
|
|
testConnector.emit("message", new libData.MessageEvent(1, dst, src, "SimpleEvent"));
|
|
assert(handled, "event was not handled");
|
|
});
|
|
it("should log errors from snoop handler", function() {
|
|
testLink.snoopEvent(SimpleEvent, async () => { throwSimple("Error"); });
|
|
testConnector.emit("message", new libData.MessageEvent(1, dst, src, "SimpleEvent"));
|
|
});
|
|
it("should throw on double registration", function() {
|
|
testLink.snoopEvent(SimpleEvent);
|
|
assert.throws(
|
|
() => testLink.snoopEvent(SimpleEvent),
|
|
new Error("Event SimpleEvent is already snooped")
|
|
);
|
|
});
|
|
it("should throw if Event has only one of jsonSchema and fromJSON", function() {
|
|
class BadEvent1 {
|
|
static type = "event";
|
|
static src = "controller";
|
|
static dst = "control";
|
|
static jsonSchema = {};
|
|
}
|
|
assert.throws(
|
|
() => testLink.snoopEvent(BadEvent1),
|
|
new Error("Event BadEvent1 has static jsonSchema but is missing static fromJSON")
|
|
);
|
|
class BadEvent2 {
|
|
static type = "event";
|
|
static src = "controller";
|
|
static dst = "control";
|
|
static fromJSON() {};
|
|
}
|
|
assert.throws(
|
|
() => testLink.snoopEvent(BadEvent2),
|
|
new Error("Event BadEvent2 has static fromJSON but is missing static jsonSchema")
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|