Skip to content

Instantly share code, notes, and snippets.

@efortner
Created November 14, 2025 01:35
Show Gist options
  • Select an option

  • Save efortner/80d6de802c9c94cd52ba4023daa1bff5 to your computer and use it in GitHub Desktop.

Select an option

Save efortner/80d6de802c9c94cd52ba4023daa1bff5 to your computer and use it in GitHub Desktop.
A more feature-rich network simulator
/*
Example Output:
Node 0 received a message from an invalid sender at address 10
Node 6 received a message from an invalid sender at address 1
Node 3 cannot send messages because it does not have access to the network
Added node 4 to the network
Node 5 cannot send messages because it does not have access to the network
Removed node 6 from the network
Node 8 cannot send messages because it does not have access to the network
Node 9 cannot send messages because it does not have access to the network
Node 0 received a message from an invalid sender at address 10
Node 1 cannot send messages because it does not have access to the network
Node 3 cannot send messages because it does not have access to the network
Node 5 cannot send messages because it does not have access to the network
Node 1 received a message from an invalid sender at address 6
Added node 8 to the network
Node 9 cannot send messages because it does not have access to the network
Node 0 received a message from an invalid sender at address 10
Node 1 cannot send messages because it does not have access to the network
Node 4 received message from node 0: Hello!
Removed node 2 from the network
Node 3 cannot send messages because it does not have access to the network
Removed node 4 from the network
Node 5 cannot send messages because it does not have access to the network
Node 6 cannot send messages because it does not have access to the network
Node 3 received a message from an invalid sender at address 7
Node 9 cannot send messages because it does not have access to the network
Node 0 received a message from an invalid sender at address 10
Removed node 1 from the network
Node 7 received message from node 0: Hello!
Node 2 cannot send messages because it does not have access to the network
Node 3 cannot send messages because it does not have access to the network
Added node 4 to the network
Removed node 5 from the network
Node 1 received a message from an invalid sender at address 6
Removed node 8 from the network
Node 9 cannot send messages because it does not have access to the network
Node 0 received a message from an invalid sender at address 10
Added node 1 to the network
Node 2 cannot send messages because it does not have access to the network
Added node 3 to the network
Added node 5 to the network
Node 6 cannot send messages because it does not have access to the network
Removed node 7 from the network
Node 3 received message from node 0: Hello!
Node 8 cannot send messages because it does not have access to the network
Added node 9 to the network
Node 0 received a message from an invalid sender at address 10
Shutting down primary node...
Node 3 received message from node 0: Hello!
Node 9 received message from node 0: Hello!
Node 3 received message from node 0: Hello!
Node 5 received message from node 0: Hello!
Node 3 received message from node 0: Hello!
Shut down with 4 messages on the dead letter queue
*/
type Address = number;
interface TransportLayerMessage {
readonly from: Address;
readonly to: Address;
readonly content: string;
}
interface TransportLayerMessageAdapter {
readonly serialize: (message: TransportLayerMessage) => Buffer;
readonly deserialize: (buffer: Buffer) => TransportLayerMessage | undefined;
}
interface ApplicationLayerMessageAdapter {
readonly serialize: (message: ApplicationLayerMessage) => string;
readonly deserialize: (
content: string,
) => ApplicationLayerMessage | undefined;
}
interface NetworkInterface {
readonly sendBytes: (bytes: Buffer) => Promise<void>;
}
interface NetworkNodeProps {
readonly address: Address;
readonly allowedConnectionRange: [Address, Address];
readonly messageAdapter: TransportLayerMessageAdapter;
readonly networkInterface: NetworkInterface;
}
type PrimaryNodeProps = NetworkNodeProps & {
readonly applicationLayerMessageAdapter: ApplicationLayerMessageAdapter;
};
type ClientNodeProps = PrimaryNodeProps & {
/* it would be cool if nodes could scan for a primary node, but I just hard coded it at address zero for now */
readonly primaryNodeAddress: Address;
readonly totalNodes: number;
readonly turnsAllowed: number;
};
interface AddToNetworkRequest {
readonly requestType: 'add-to-network';
}
interface DropFromNetworkRequest {
readonly requestType: 'drop-from-network';
}
interface SendMessageRequest {
readonly requestType: 'send-message';
readonly destination: Address;
readonly messageContent: string;
}
type ApplicationLayerMessage =
| AddToNetworkRequest
| DropFromNetworkRequest
| SendMessageRequest;
interface VirtualNetworkInterfaceProps {
readonly transportLayerMessageAdapter: TransportLayerMessageAdapter;
readonly maximumNetworkLatencyMs: number;
}
/* I normally use a library like ts-autoguard to write type guards automatically and ensure correctness */
const isTransportLayerMessage = (
obj: unknown,
): obj is TransportLayerMessage => {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const castObject = obj as Record<string, unknown>;
return (
typeof castObject['from'] === 'number' &&
typeof castObject['to'] === 'number' &&
typeof castObject['content'] === 'string'
);
};
const isApplicationLayerMessage = (
obj: unknown,
): obj is ApplicationLayerMessage => {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const castObject = obj as Record<string, unknown>;
return (
castObject['requestType'] === 'add-to-network' ||
castObject['requestType'] === 'send-message' ||
castObject['requestType'] === 'drop-from-network'
);
/* etc */
};
class JsonApplicationLayerMessageAdapter
implements ApplicationLayerMessageAdapter
{
public readonly deserialize = (
content: string,
): ApplicationLayerMessage | undefined => {
let parsedJson: unknown;
try {
parsedJson = JSON.parse(content);
} catch (error) {
if (error instanceof SyntaxError) {
return undefined;
} else {
throw error;
}
}
if (isApplicationLayerMessage(parsedJson)) {
return parsedJson;
}
return undefined;
};
public readonly serialize = (message: ApplicationLayerMessage): string => {
return JSON.stringify(message);
};
}
class JsonTransportLayerMessageAdapter implements TransportLayerMessageAdapter {
public readonly deserialize = (
buffer: Buffer,
): TransportLayerMessage | undefined => {
const stringBuffer = buffer.toString('utf-8');
let parsedJson: unknown;
try {
parsedJson = JSON.parse(stringBuffer);
} catch (error) {
if (error instanceof SyntaxError) {
return undefined;
} else {
throw error;
}
}
if (isTransportLayerMessage(parsedJson)) {
return parsedJson;
}
return undefined;
};
public readonly serialize = (message: TransportLayerMessage): Buffer => {
return Buffer.from(JSON.stringify(message));
};
}
abstract class NetworkNode {
protected constructor(protected readonly props: NetworkNodeProps) {}
public abstract readonly onMessageReceived: (
message: TransportLayerMessage,
) => void;
public get address() {
return this.props.address;
}
public readonly deliverBytes = async (buffer: Buffer): Promise<void> => {
const receivedMessage = this.props.messageAdapter.deserialize(buffer);
if (!receivedMessage) {
console.info(
`Node ${this.props.address} received an unparseable message`,
);
return;
}
const [minimumAddress, maximumAddress] = this.props.allowedConnectionRange;
if (
receivedMessage.from < minimumAddress ||
receivedMessage.from >= maximumAddress
) {
console.info(
`Node ${this.props.address} received a message from an invalid sender at address ${receivedMessage.from}`,
);
return;
}
this.onMessageReceived(receivedMessage);
};
public readonly sendMessage = async (
message: TransportLayerMessage,
): Promise<void> => {
const messageBytes = this.props.messageAdapter.serialize(message);
await this.props.networkInterface.sendBytes(messageBytes);
};
}
class PrimaryNode extends NetworkNode {
private readonly _nodesInNetwork: Set<Address> = new Set();
private readonly _outboundMessageQueue: SendMessageRequest[] = [];
private readonly _deadLetterQueue: SendMessageRequest[] = [];
private _startShutdown: boolean = false;
constructor(protected override readonly props: PrimaryNodeProps) {
super(props);
}
public readonly shutdown = () => {
this._startShutdown = true;
console.info('Shutting down primary node...');
};
public readonly routeMessages = async (): Promise<void> => {
while (!this._startShutdown || this._outboundMessageQueue.length > 0) {
/* JavaScript does not have a built-in efficient queue structure, so I am using the Array as an inefficient queue */
const nextMessageToProcess = this._outboundMessageQueue.shift();
if (nextMessageToProcess) {
if (this._nodesInNetwork.has(nextMessageToProcess.destination)) {
const serializedMessage =
this.props.applicationLayerMessageAdapter.serialize(
nextMessageToProcess,
);
await this.sendMessage({
to: nextMessageToProcess.destination,
from: this.props.address,
content: serializedMessage,
});
} else {
if (!this._startShutdown) {
this._outboundMessageQueue.push(nextMessageToProcess);
} else {
this._deadLetterQueue.push(nextMessageToProcess);
}
}
}
await yieldToEventLoop();
}
console.info(
`Shut down with ${this._deadLetterQueue.length} messages on the dead letter queue`,
);
};
public readonly onMessageReceived = (
message: TransportLayerMessage,
): void => {
const applicationMessage =
this.props.applicationLayerMessageAdapter.deserialize(message.content);
if (!applicationMessage) {
console.info(
`Node ${this.props.address} received a non-application message`,
);
return;
}
switch (applicationMessage.requestType) {
case 'send-message': {
if (this._nodesInNetwork.has(message.from)) {
this._outboundMessageQueue.push(applicationMessage);
} else {
console.info(
`Node ${message.from} cannot send messages because it does not have access to the network`,
);
}
break;
}
case 'drop-from-network': {
this._nodesInNetwork.delete(message.from);
console.info(`Removed node ${message.from} from the network`);
break;
}
case 'add-to-network': {
this._nodesInNetwork.add(message.from);
console.info(`Added node ${message.from} to the network`);
break;
}
}
};
}
class ClientNode extends NetworkNode {
private _turnsRemaining: number;
constructor(protected override readonly props: ClientNodeProps) {
super(props);
this._turnsRemaining = this.props.turnsAllowed;
}
public readonly run = async (): Promise<void> => {
while (this._turnsRemaining > 0) {
const availableActions = [
this.runInvalidMessageAction,
this.runDropAction,
this.runJoinAction,
/* bias towards choosing a valid send message action */
...Array.from({ length: 5 }).map(() => this.runValidMessageAction),
];
const selectedAction =
availableActions[randomRange(availableActions.length)]!;
await selectedAction();
this._turnsRemaining -= 1;
await yieldToEventLoop();
}
};
public readonly onMessageReceived = (
message: TransportLayerMessage,
): void => {
const applicationLayerMessage =
this.props.applicationLayerMessageAdapter.deserialize(message.content);
if (
applicationLayerMessage &&
applicationLayerMessage.requestType === 'send-message'
) {
console.info(
`Node ${this.props.address} received message from node ${message.from}: ${applicationLayerMessage.messageContent}`,
);
}
};
private readonly runJoinAction = async (): Promise<void> => {
await this.sendMessage({
content: this.props.applicationLayerMessageAdapter.serialize({
requestType: 'add-to-network',
}),
from: this.props.address,
to: this.props.primaryNodeAddress,
});
};
private readonly runDropAction = async (): Promise<void> => {
await this.sendMessage({
content: this.props.applicationLayerMessageAdapter.serialize({
requestType: 'drop-from-network',
}),
from: this.props.address,
to: this.props.primaryNodeAddress,
});
};
private readonly runValidMessageAction = async (): Promise<void> => {
await this.sendMessage({
content: this.props.applicationLayerMessageAdapter.serialize({
requestType: 'send-message',
destination: randomRange(this.props.totalNodes, 1),
messageContent: 'Hello!',
}),
from: this.props.address,
to: this.props.primaryNodeAddress,
});
};
private readonly runInvalidMessageAction = async (): Promise<void> => {
await this.sendMessage({
content: 'Invalid destination message',
from: this.props.address,
to: randomRange(
10 + this.props.primaryNodeAddress,
1 + this.props.primaryNodeAddress,
),
});
};
}
class VirtualNetworkInterface implements NetworkInterface {
private readonly _nodesByAddress: Map<Address, NetworkNode> = new Map();
private readonly _allNodes: NetworkNode[] = [];
constructor(private readonly props: VirtualNetworkInterfaceProps) {}
public readonly addNodes = (nodes: NetworkNode[]) => {
this._allNodes.push(...nodes);
this._allNodes.forEach((node) =>
this._nodesByAddress.set(node.address, node),
);
};
public readonly sendBytes = async (bytes: Buffer): Promise<void> => {
await this.simulateNetworkLatency();
const transportLayerMessage =
this.props.transportLayerMessageAdapter.deserialize(bytes);
if (transportLayerMessage !== undefined) {
const destinationNode = this._nodesByAddress.get(
transportLayerMessage.to,
);
if (destinationNode) {
await destinationNode.deliverBytes(bytes);
} else {
console.info(
`Could not deliver message: no known node at ${transportLayerMessage.to}`,
);
}
}
};
private readonly simulateNetworkLatency = (): Promise<void> =>
new Promise((accept) =>
setTimeout(
() => accept(),
randomRange(this.props.maximumNetworkLatencyMs),
),
);
}
const yieldToEventLoop = (): Promise<void> =>
new Promise((accept) => setImmediate(() => accept()));
const randomRange = (maximum: number, minimum?: number): number => {
const resolvedMinimum = minimum ?? 0;
const range = maximum - resolvedMinimum;
return Math.trunc(Math.random() * range) + resolvedMinimum;
};
(async () => {
const TOTAL_CLIENT_NODES = 10;
const MAXIMUM_NETWORK_LATENCY_MS = 0;
const PRIMARY_NODE_ADDRESS = 0;
const MAXIMUM_TURNS_ALLOWED = 10;
const virtualNetworkInterface = new VirtualNetworkInterface({
transportLayerMessageAdapter: new JsonTransportLayerMessageAdapter(),
maximumNetworkLatencyMs: MAXIMUM_NETWORK_LATENCY_MS,
});
const clientNodes = Array.from({ length: TOTAL_CLIENT_NODES }).map(
(_, index) =>
new ClientNode({
address: index + PRIMARY_NODE_ADDRESS + 1,
/* only allow connections from the primary node */
allowedConnectionRange: [
PRIMARY_NODE_ADDRESS,
PRIMARY_NODE_ADDRESS + 1,
],
/* these dependencies could be singletons in our sim, but we imagine they exist on different devices */
applicationLayerMessageAdapter:
new JsonApplicationLayerMessageAdapter(),
messageAdapter: new JsonTransportLayerMessageAdapter(),
networkInterface: virtualNetworkInterface,
primaryNodeAddress: PRIMARY_NODE_ADDRESS,
turnsAllowed: MAXIMUM_TURNS_ALLOWED,
totalNodes: TOTAL_CLIENT_NODES,
}),
);
const primaryNode = new PrimaryNode({
address: PRIMARY_NODE_ADDRESS,
allowedConnectionRange: [PRIMARY_NODE_ADDRESS, TOTAL_CLIENT_NODES],
applicationLayerMessageAdapter: new JsonApplicationLayerMessageAdapter(),
messageAdapter: new JsonTransportLayerMessageAdapter(),
networkInterface: virtualNetworkInterface,
});
virtualNetworkInterface.addNodes([...clientNodes, primaryNode]);
const networkRoutingPromise = primaryNode.routeMessages();
await Promise.all(clientNodes.map((node) => node.run()));
primaryNode.shutdown();
await networkRoutingPromise;
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment