WebSockets are amazingly flexible but can be finnicky to set up. Tocket aims to streamline the implementation of WebSocket Server/Client pairs in a way that's easy to set up and highly customizable.
What Tocket and other middleware does under the hood is designating individual messages with unique IDs and relationships to build reliant request/response chains.
Tocket goes another step further: Instead of dealing with individual requests, endpoints define pre-se communication routines in which information is received and sent via asynchronous functions. As the library is written in TypeScript, all outputs and input can be explicitly typed.
Tocket also offers advanced functionality geared towards debugging, userscripts, scraping, and so on by intercepting third party WebSocket connections, logging and modifying messages, while circumventing other middleware (which is why some functionality is built built with the assumption that you don't control the WebSocket you are communicating with).
Tocket expects the content of messages to be serializable into JSON and I have honestly no idea how fast and/or scalable it performs in comparison to other webSocket libraries.
npm install tocket
If run in a node context with TocketClientNode, the WebSocket runs via ws, while the TocketClientBrowser class just uses the native WebSocket functionality.
This section covers the basics of setting up a server and client with Tocket, for details you can consult the Documentation
By default the Socket Server is initialized with a port number.
Communication presets are created via a call to register
and their logic is assigned in a method
function.
import {TocketServer} from 'tocket';
const server = new TocketServer(3333, {name: 'exampleServer'});
const vehicleData = {
van: {wheels: 4, doors: 4, licensePlate: '123-ABC'},
bus: {wheels: 6, doors: 2, licensePlate: '456-DEF'},
bike: {wheels: 2, doors: 0, licensePlate: '789-GHI'}
};
server.register('vehicleHandler').method = ({content, send}) => {
//catching invalid requests
if(!content) return
const {vehicleType, dataRequest} = content;
const result = vehicleData[vehicleType][dataRequest];
send(result);
};
Clients receive the address of the primary server they are connecting to. Additional connections can be added later as well
import {TocketClientNode} from 'tocket';
const client = new TocketClientNode('ws://example-server-net:3333');
client.register('vehicleDataRequest', 'vehicleHandler').method = async ({content, get}) => {
const dataRequest = content === 'bus' ? 'licensePlate' : 'wheels';
const response = await get({vehicleType: content, dataRequest});
//in case the server did not respond
if (response === undefined) throw new Error('No Data Received');
return response;
};
const testData = await client.launch.vehicleDataRequest('bike');
Usually a client preset registered with an explicitly set handler preset on the server. This is however optional; If no handler name is passed, the Client will assume that the server has a preset with the same name and direct messages there.
Presets can then be triggered via the launch
object which is the primary means of interacting with the socket.
In our example the Server/Client logic weaves together like this:
const dataRequest = content === 'bus' ? 'licensePlate' : 'wheels';
const response = await /*
if(!content) return
const {vehicleType, dataRequest} = content;
const result = vehicleData[vehicleType][dataRequest];
send(result);
*/ get({vehicleType: content, dataRequest});
//in case the server did not respond
if (response === undefined) throw new Error('No Data Received');
return response;
It is of course possible to use an arbitrary number of get
calls to bounce information back and forth as needed.
Typing your WebSocket interfaces is one of the core features of Tocket.
Types are defined centrally in a so called "Socket Definition" which is passed as a type parameter to the constructor.
If a Socket is initialized with a definition, only methods with names present in the presets
of that definition can be registered on the socket and their method handlers, content
, get
, send
, etc is typed accordingly.
import {TocketServer} from 'tocket';
interface ServerDefinition {
presets:{
vehicleHandler:{
content:{
vehicleType:'bus'|'van'|'bike',
dataRequest:'wheels'|'licensePlate'|'doors'
},
sends:string|number
}
}
}
const server = new TocketServer<ServerDefinition>(3333, {name: 'exampleServer'});
const vehicleData = {
van: {wheels: 4, doors: 4, licensePlate: '123-ABC'},
bus: {wheels: 6, doors: 2, licensePlate: '456-DEF'},
bike: {wheels: 2, doors: 0, licensePlate: '789-GHI'}
} as const;
server.register('vehicleHandler').method = ({content, send}) => {
//Just to be safe all types are nullable
if (!content) return;
const {vehicleType, dataRequest} = content;
const result = vehicleData[vehicleType][dataRequest];
//Compiler would complain if we sent the wrong type.
send(result);
};
And in the following example the Client is fully typed, which includes not only the types for all parameters in the method
handler but also which keys exist in the launch
and what arguments are used to execute a preset.
import {TocketClientNode} from 'tocket';
interface ClientDefinition {
presets:{
vehicleDataRequest:{
content:'bus'|'van'|'bike',
sends: {vehicleType:'bus'|'van'|'bike', dataRequest:'wheels'|'licensePlate'|'doors'},
gets:string|number,
returns:Promise<string|number>
}
}
}
const client = new TocketClientNode<ClientDefinition>('ws://example-server-net:3333');
client.register('vehicleDataRequest', 'vehicleHandler').method = async ({content, get}) => {
const dataRequest = content === 'bus' ? 'licensePlate' : 'wheels';
const response = await get({vehicleType: content, dataRequest});
//in case the server did not respond
if (response === undefined) throw new Error('No Data Received');
return response;
};
//Full intellisense for the vehicleDataRequest function-call
const testData = await client.launch.vehicleDataRequest('bike');
Note how the types for the content of the sends
type on the Server Side is mirrored on the gets
type of the client, while the client's sends
type is received as the server's content
.
Designing Communication Presets is the main aspect of working with Tocket, this is done with and a Communication Handler which is passed as an argument into the method of the Communication Preset. We'll go over the basic content of the Communication Handler in this section.
content
The Communication Handler's content property holds the data with which the Communication Preset was initially called, either through the launch
object or the first send
or get
call in a request chain.
send(msg?: any, handlerFunction?: string, targetSelector?: ConnectionFilter, transferOptions?: OptionsList, adapterOptions?: Record<string, any>)
This method is used to send Data to other sockets. The Communication Preset is initialized with defaults for all parameters except msg
, which is the actual content we want send. As usual this should be data that can be parsed as JSON.
We can use the handlerFunction
parameter send our data to a different handler preset on the other socket than the one specified during the Preset's register
call.
targetSelector
overrides the default target socket. In the case of the Client all Server connections are named (with the server at the address in the constructor receiving the name "main"), and their names are used to target requests.
On Servers however Clients do not have individual names, instead being referenced by automatically generated GUIDs. In order to target the right clients a server can pass an object as an ConnectionFilter
which will filter clients based on the contents of their customData properties.
Let's say we have instantiated a client with the customData object {type:'nodeClient'}
. We can use this same object in the targetSelector
parameter of a server send
or get
method to target only clients with the same property. We can specify as many filters as we want: If we pass {type:'nodeClient', age:30}
only clients in which both properties have the correct value will receive the message.
We can further control the socket behavior with the transferOptions
parameter which can set a number of modifiers, the most important one for send
being sendMode
. If set to 'first' only the first client matching the targetSelector will receive the message, if set to 'all' (the default), all matching clients receive it.
get(msg?: any, handlerFunction?: string, timeout?: number, targetSelector?: ConnectionFilter, transferOptions?: OptionsList, adapterOptions?: Record<string, any>)
The get
method works the same as send, except it waits for responses and returns them. For that reason it can receive a timeout
parameter which can be used to customize timeouts on an individual basis, in case some steps of a communication preset are more or less responsive than others.
The filtering of receiving connections works exactly as with send
, but in our adapterOptions
we can also set a getMode
, which is especially useful in the case of servers sending to multiple clients; We can set the property to 'all' in which case the communication Handler will wait until all targeted clients have responded and then send all responses as an array. The other option is 'race' which will immediately return the first received response and discard all others.
getCustomData(key?: string, targetSelector?: ConnectionFilter, transferOptions?: OptionsList)
Access the customData of the client/server you are currently connected to, or use the targetSelector
to select any other connected socket.
If called without the key
parameter the entire customData object will be returned.
getCustomData is not asynchronous, because it does not directly query the client, instead whenever a client's or server's customData object is updated the change is immediately propagated through a separate connection to all connected sockets, which save the current state locally and this local copy is what the getCustomData method will query.
setCustomData(key: string, value: Serializable)
This method is used to modify the customData of the current socket.
The customData can hold any kind of data that serializable into JSON and a client or server can only update its own customData object. As stated above all changes are propagated immediately. For this reason, putting huge chunks of data into your customData object is probably not the best idea.
Generated using TypeDoc