@fakehost/signalr
Version:
A Fake Signalr Service for faking/mocking signalr hub services for testing, prototyping, and demoing
460 lines (455 loc) • 15 kB
JavaScript
// src/FakeSignalrHub.ts
import { Subject } from "@microsoft/signalr";
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
// src/ClientState.ts
var ClientState = class {
constructor(connection) {
this.connection = connection;
this.state = {};
// streams service -> client
this.subscriptions = /* @__PURE__ */ new Map();
// streams client -> service
this.subjects = /* @__PURE__ */ new Map();
}
unsubscribe(id) {
const subscription = this.subscriptions.get(id);
if (!subscription)
return;
if ("unsubscribe" in subscription) {
subscription.unsubscribe();
} else {
subscription.dispose();
}
}
cleanup(id) {
const subject = this.subjects.get(id);
if (subject) {
subject.complete();
this.subjects.delete(id);
}
const subscription = this.subscriptions.get(id);
if (subscription) {
this.unsubscribe(id);
this.subscriptions.delete(id);
}
}
dispose() {
Array.from(this.subscriptions.keys()).forEach((id) => this.unsubscribe(id));
this.subjects.forEach((subject) => subject.complete());
}
setState(key, value) {
this.state[key] = value;
}
};
// src/messageTypes.ts
var isHandshakeMessage = (message) => {
return message.protocol !== void 0;
};
// src/messagePack/BinaryMessageFormat.ts
var BinaryMessageFormat = class {
// The length prefix of binary messages is encoded as VarInt. Read the comment in
// the BinaryMessageParser.TryParseMessage for details.
static write(output) {
let size = output.byteLength || output.length;
const lenBuffer = [];
do {
let sizePart = size & 127;
size = size >> 7;
if (size > 0) {
sizePart |= 128;
}
lenBuffer.push(sizePart);
} while (size > 0);
size = output.byteLength || output.length;
const buffer = new Uint8Array(lenBuffer.length + size);
buffer.set(lenBuffer, 0);
buffer.set(output, lenBuffer.length);
return buffer.buffer;
}
static parse(input) {
const result = [];
const uint8Array = new Uint8Array(input);
const maxLengthPrefixSize = 5;
const numBitsToShift = [0, 7, 14, 21, 28];
for (let offset = 0; offset < input.byteLength; ) {
let numBytes = 0;
let size = 0;
let byteRead;
do {
byteRead = uint8Array[offset + numBytes];
size = size | (byteRead & 127) << numBitsToShift[numBytes];
numBytes++;
} while (numBytes < Math.min(maxLengthPrefixSize, input.byteLength - offset) && (byteRead & 128) !== 0);
if ((byteRead & 128) !== 0 && numBytes < maxLengthPrefixSize) {
throw new Error("Cannot read message size.");
}
if (numBytes === maxLengthPrefixSize && byteRead > 7) {
throw new Error("Messages bigger than 2GB are not supported.");
}
if (uint8Array.byteLength >= offset + numBytes + size) {
result.push(
uint8Array.slice ? uint8Array.slice(offset + numBytes, offset + numBytes + size) : uint8Array.subarray(offset + numBytes, offset + numBytes + size)
);
} else {
throw new Error("Incomplete message.");
}
offset = offset + numBytes + size;
}
return result;
}
};
// src/messagePack/parse.ts
import { Decoder } from "@msgpack/msgpack";
var parse = (input) => {
const buffer = Buffer.from(input);
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
);
const messages = BinaryMessageFormat.parse(arrayBuffer);
const hubMessages = new Array();
for (const message of messages) {
const parsedMessage = parseMessage(message);
if (parsedMessage) {
hubMessages.push(parsedMessage);
}
}
return hubMessages;
};
var parseMessage = (input) => {
if (input.length === 0) {
throw new Error("Invalid payload.");
}
const decoder = new Decoder();
const properties = decoder.decode(input);
if (properties.length === 0 || !(properties instanceof Array)) {
throw new Error("Invalid payload.");
}
const messageType = properties[0];
switch (messageType) {
case 1 /* Invocation */:
return {
type: 1 /* Invocation */,
arguments: properties[4],
invocationId: properties[2],
streamIds: properties[5],
target: properties[3]
};
case 2 /* StreamItem */:
return {
type: 2 /* StreamItem */,
invocationId: properties[2],
item: properties[3]
};
case 3 /* Completion */: {
const resultKind = properties[3];
return {
type: 3 /* Completion */,
error: resultKind === 1 ? properties[4] : void 0,
invocationId: properties[2],
result: resultKind === 1 ? void 0 : properties[4]
};
}
case 6 /* Ping */:
return {
type: 6 /* Ping */
};
case 7 /* Close */:
return {
type: 7 /* Close */,
allowReconnect: properties.length >= 3 ? properties[2] : void 0,
error: properties[1]
};
case 5 /* CancelInvocation */:
return {
type: 5 /* CancelInvocation */,
invocationId: properties[2]
};
case 4 /* StreamInvocation */:
return {
type: 4 /* StreamInvocation */,
invocationId: properties[2],
target: properties[3],
arguments: properties[4]
};
default:
console.log("Unknown message type '" + messageType + "' ignored.");
return null;
}
};
// src/FakeSignalrHub.ts
var protocol = new MessagePackHubProtocol();
var TERMINATING_CHAR = String.fromCharCode(30);
var FakeSignalrHub = class {
constructor(path, receivers = {}, format) {
this.path = path;
this.receivers = receivers;
this.format = format;
// active client connections to this hub
this.clients = /* @__PURE__ */ new Map();
// methods from Hub. Not typed due to casing of methods (camelCase in ts vs PascalCase in C#)
this.handlers = /* @__PURE__ */ new Map();
this.messageProtocol = /* @__PURE__ */ new Map();
this.connectionEvents = /* @__PURE__ */ new Map();
}
disconnect(options) {
var _a;
(_a = this.host) == null ? void 0 : _a.disconnect({ path: this.path, ...options });
}
setHost(host) {
this.host = host;
this.host.on("connection", this.onConnection.bind(this));
this.host.on("disconnection", this.onDisconnection.bind(this));
this.host.on("message", (e) => {
if (e.connection.url.pathname !== this.path)
return;
if (!this.messageProtocol.has(e.connection.id)) {
this.handleHandshake(e.connection, e.message);
return;
}
const messages = this.deserialize(e.connection, e.message);
messages.forEach((message) => this.onMessage.bind(this)(e.connection, message));
});
}
/**
* There can be differences in casing between the client typescript and the server handler methods in C#.
* This method formats the target to match the casing of the server.
* @param s
* @returns
*/
formatTarget(s) {
if (this.format === "capitalize" && typeof s === "string") {
return capitalize(s);
} else if (typeof this.format === "function") {
return this.format(s);
} else {
return s;
}
}
onConnection({ connection }) {
if (connection.url.pathname !== this.path)
return;
this.clients.set(connection.id, new ClientState(connection));
}
onDisconnection({ connection }) {
var _a;
if (connection.url.pathname !== this.path)
return;
(_a = this.clients.get(connection.id)) == null ? void 0 : _a.dispose();
this.clients.delete(connection.id);
const handlers = this.connectionEvents.get(`${connection.id}.disconnect`);
handlers == null ? void 0 : handlers.forEach((handler) => handler());
this.connectionEvents.delete(`${connection.id}.disconnect`);
}
handleHandshake(connection, message) {
const [parsed] = message.toString().split(TERMINATING_CHAR).filter(Boolean).map((m) => JSON.parse(m));
if (!isHandshakeMessage(parsed)) {
console.error("Expected initial handshake message, but none was received.");
connection.close({ code: 1002, reason: "No handshake supplied" });
return;
}
this.messageProtocol.set(connection.id, parsed.protocol);
connection.write(JSON.stringify({ type: 0 }) + TERMINATING_CHAR);
}
serialize(connection, message) {
switch (this.messageProtocol.get(connection.id)) {
case "json":
return JSON.stringify(message) + TERMINATING_CHAR;
case "messagepack":
return protocol.writeMessage(message);
default:
throw new Error("Unknown connection mode");
}
}
deserialize(connection, message) {
switch (this.messageProtocol.get(connection.id)) {
case "json": {
return message.toString().split(TERMINATING_CHAR).filter(Boolean).map((m) => JSON.parse(m));
}
case "messagepack": {
return parse(message);
}
default:
throw new Error("Unknown connection mode");
}
}
async onMessage(connection, message) {
var _a, _b;
const connectionId = connection.id;
const client = this.clients.get(connectionId);
if (!client)
return;
switch (message.type) {
case 1 /* Invocation */: {
const handler = this.handlers.get(message.target);
if (message.streamIds) {
message.streamIds.forEach(async (streamId) => {
const subject = new Subject();
client.subjects.set(streamId, subject);
await (handler == null ? void 0 : handler.apply(this.getSignalrInstance(connectionId), [subject]));
});
return;
}
try {
const result = await (handler == null ? void 0 : handler.apply(
this.getSignalrInstance(connectionId),
(_a = message.arguments) != null ? _a : []
));
return connection.write(
this.serialize(connection, {
type: 3 /* Completion */,
invocationId: message.invocationId,
result
})
);
} catch (error) {
return connection.write(
this.serialize(connection, {
type: 3 /* Completion */,
invocationId: message.invocationId,
error: stringifyError(error)
})
);
}
}
case 4 /* StreamInvocation */: {
const handler = this.handlers.get(message.target);
const result = await (handler == null ? void 0 : handler.apply(
this.getSignalrInstance(connectionId),
(_b = message.arguments) != null ? _b : []
));
const subscription = result.subscribe({
next: (value) => {
connection.write(
this.serialize(connection, {
type: 2 /* StreamItem */,
invocationId: message.invocationId,
item: value
})
);
},
error: (error) => {
connection.write(
this.serialize(connection, {
type: 3 /* Completion */,
invocationId: message.invocationId,
error: stringifyError(error)
})
);
},
complete: () => {
connection.write(
this.serialize(connection, {
type: 3 /* Completion */,
invocationId: message.invocationId
})
);
}
});
client.subscriptions.set(message.invocationId, subscription);
return;
}
case 2 /* StreamItem */: {
const subject = client.subjects.get(message.invocationId);
return subject == null ? void 0 : subject.next(message.item);
}
case 5 /* CancelInvocation */: {
client.cleanup(message.invocationId);
connection.write(
this.serialize(connection, {
type: 3 /* Completion */,
invocationId: message.invocationId
})
);
return;
}
case 3 /* Completion */: {
client.cleanup(message.invocationId);
return;
}
case 6 /* Ping */:
return connection.write(this.serialize(connection, { type: 6 /* Ping */ }));
default:
console.warn("Not handled", message);
}
}
getSignalrInstance(currentConnectionId) {
const client = this.clients.get(currentConnectionId);
if (!client) {
throw new Error("Excepted a client but there was none");
}
const createClientSender = (predicate) => {
const result = Object.keys(this.receivers).reduce((acc, target) => {
acc[target] = (...args) => {
Array.from(this.clients.entries()).filter(([connId]) => predicate(connId)).forEach(([, client2]) => {
client2.connection.write(
this.serialize(client2.connection, {
type: 1 /* Invocation */,
target: this.formatTarget(target),
arguments: args
})
);
});
};
return acc;
}, {});
return result;
};
const signalrThis = {
Connection: {
get id() {
return client.connection.id;
},
setState: (key, value) => {
client.setState(key, value);
},
getState: (key) => {
return client.state[key];
},
addEventHandler: (eventName, handler) => {
const handlers = this.connectionEvents.get(`${currentConnectionId}.${eventName}`) || /* @__PURE__ */ new Set();
handlers.add(handler);
this.connectionEvents.set(`${currentConnectionId}.${eventName}`, handlers);
},
removeEventHandler: (eventName, handler) => {
const handlers = this.connectionEvents.get(`${currentConnectionId}.${eventName}`) || /* @__PURE__ */ new Set();
handlers.delete(handler);
this.connectionEvents.set(`${currentConnectionId}.${eventName}`, handlers);
}
},
Clients: {
get All() {
return createClientSender(() => true);
},
get Others() {
return createClientSender((id) => id !== client.connection.id);
},
get Caller() {
return createClientSender((id) => id === client.connection.id);
},
Client: (clientId) => {
return createClientSender((id) => id === clientId);
}
}
};
return signalrThis;
}
get thisInstance() {
throw new Error("Not callable. Used only for type inference.");
}
register(target, handler) {
this.handlers.set(this.formatTarget(target), handler);
}
};
var capitalize = (key) => key.slice(0, 1).toUpperCase() + key.slice(1);
var stringifyError = (e) => {
if (typeof e === "string")
return e;
if (e instanceof Error)
return e.message;
return JSON.stringify(e);
};
export {
FakeSignalrHub
};
//# sourceMappingURL=index.js.map