Skip to content

Commit

Permalink
Implement a simple Tachyon protocol client
Browse files Browse the repository at this point in the history
  • Loading branch information
p2004a committed Jun 9, 2024
1 parent c755833 commit 728e932
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 0 deletions.
55 changes: 55 additions & 0 deletions src/tachyonClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import test, { after, afterEach } from 'node:test';
import { equal } from 'node:assert/strict';
import { once } from 'node:events';

import { TachyonClient } from './tachyonClient.js';
import { createTachyonServer } from './tachyonServer.fake.js';
import { deepEqual } from 'node:assert';
import { TachyonMessage } from './tachyonTypes.js';

// Let's reuse the same server for all tests to make them quicker.
const server = await createTachyonServer({ clientId: 'c', clientSecret: 's' });
await server.start();
const port = server.fastifyServer.addresses()[0].port;
after(() => server.close());
afterEach(() => server.removeAllListeners());

test('simple full example', async () => {
server.on('connection', (conn) => {
conn.on('message', (msg) => {
equal(msg.type, 'request');
equal(msg.commandId, 'test/command');
deepEqual(msg.data, { test: 'test' });
conn.send({
type: 'response',
commandId: msg.commandId,
messageId: msg.messageId,
status: 'success',
});
});
});

const client = new TachyonClient({
clientId: 'c',
clientSecret: 's',
hostname: 'localhost',
port,
});
await once(client, 'connected');
client.send({
type: 'request',
commandId: 'test/command',
messageId: 'test-message1',
data: { test: 'test' },
});
const msg = (await once(client, 'message')) as [TachyonMessage];
deepEqual(msg[0], {
type: 'response',
commandId: 'test/command',
messageId: 'test-message1',
status: 'success',
});
client.close();
});

// TODO: Add more tests then only a simple happy path.
132 changes: 132 additions & 0 deletions src/tachyonClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* A client for the Tachyon protocol.
*
* The client only handles the connection and message sending, it does not handle the
* protocol messages themselves in a semantic way. The messages are emitted as events
* and the client can send messages to the server.
*/
import { TypedEmitter } from 'tiny-typed-emitter';
import { TachyonMessage, TACHYON_PROTOCOL_VERSION } from './tachyonTypes.js';
import { getAccessToken } from './oauth2Client.js';
import WebSocket from 'ws';

export interface TachyonClientOpts {
/**
* The OAuth2 client ID for authentication.
*/
clientId: string;

/**
* The OAuth2 client secret for authentication.
*/
clientSecret: string;

/**
* The hostname of the Tachyon server.
*/
hostname: string;

/**
* The port of the Tachyon server, if not set uses the default port for the scheme.
*/
port?: number;

/**
* Whether to use HTTPS or not, defaults to true with the exception of localhost.
*/
secure?: boolean;
}

enum ClientState {
STARTING,
CONNECTED,
CLOSED,
}

export class TachyonClient extends TypedEmitter<{
connected: () => void;
close: () => void;
error: (err: Error) => void;
message: (msg: TachyonMessage) => void;
}> {
private state = ClientState.STARTING;
private clientCredentials: { id: string; secret: string };
private baseOAuth2Url: string;
private tachyonUrl: string;
private ws?: WebSocket;

public constructor(clientOpts: TachyonClientOpts) {
super();

const secure = clientOpts.secure ?? clientOpts.hostname !== 'localhost';
const portSuffix = clientOpts.port ? `:${clientOpts.port}` : '';
this.baseOAuth2Url = `${secure ? 'https' : 'http'}://${clientOpts.hostname}${portSuffix}`;
this.tachyonUrl = `${secure ? 'wss' : 'ws'}://${clientOpts.hostname}${portSuffix}/tachyon`;
this.clientCredentials = { id: clientOpts.clientId, secret: clientOpts.clientSecret };

this.connect().catch((err) => this.handleError(err));
}

private async connect() {
const accessToken = await getAccessToken(
this.baseOAuth2Url,
this.clientCredentials.id,
this.clientCredentials.secret,
'tachyon.lobby',
);
const ws = new WebSocket(this.tachyonUrl, [TACHYON_PROTOCOL_VERSION], {
headers: {
authorization: `Bearer ${accessToken}`,
},
});
this.ws = ws;

ws.on('open', () => {
this.state = ClientState.CONNECTED;
this.emit('connected');
});

ws.on('error', (err) => this.handleError(err));
ws.on('close', () => this.close());

ws.on('message', (msg, isBinary) => {
if (isBinary) {
ws.close(1003, 'Binary messages are not supported');
this.close();
return;
}
try {
const tachyonMsg = JSON.parse(msg.toString('utf-8'));
this.emit('message', tachyonMsg);
} catch (e) {
ws.close(1008, 'Failed to parse base tachyon message');
this.close();
return;
}
});
}

private handleError(err: Error) {
if (this.state === ClientState.CLOSED) return;
this.emit('error', err);
this.close();
}

public send(msg: TachyonMessage): void {
if (this.state !== ClientState.CONNECTED) {
throw new Error('Client is not connected');
}
if (this.ws) {
this.ws.send(JSON.stringify(msg));
}
}

public close() {
if (this.state === ClientState.CLOSED) return;
this.state = ClientState.CLOSED;
if (this.ws) {
this.ws.close(1000);
}
this.emit('close');
}
}

0 comments on commit 728e932

Please sign in to comment.