Created
November 14, 2025 01:35
-
-
Save efortner/80d6de802c9c94cd52ba4023daa1bff5 to your computer and use it in GitHub Desktop.
A more feature-rich network simulator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| 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