URSYS Network Concepts - dsriseah/ursys GitHub Wiki

This is an overview of the technical underpinnings of URSYS Networking (also called URNET). For developers not implementing the network infrastructure, the page Using URSYS Messages may be more useful.

Technical Details

The net addon is a modular version of URSYS messaging (aka URNET), which is used to implement our asynchronous data passing between multiple webapps by using the server as the messaging hub. It's similar to other message-based systems like MQTT except that URNET provides a transaction call that returns data.

URNET is one of the key pillars of our web application operations, allowing programmers without experience in network asynchronous programming to work with our data. URNET is the basis for our controllers, rendering system, and coordination between web applications.

URNET Refresher

// EP is the "endpoint" that this code talks to
// using URNET's various network calls (there are local versions too)
EP.netCall('SRV:GET_DATA',{ key: 'foo' }).then( data => console.log('got data',data));
EP.netSend('NET:VOTE',{ pollName:'bananas', choice: 'ok I guess' });
EP.netSignal('NET:NOTICE', { status:'alive', name:'Felix' });
EP.netPing('NET:WHO_IS_THERE').then( uaddrs => console.log('list of addr',uaddrs));

// using URNET's message declaration to receive messages
// data return works only when netCall() is used to invoke the message
EP.addMessageHandler('SRV:GET_DATA', data => {
  // do something with incoming data, then return it to caller
  return { note: 'sri was here!', ...data };
});

Tip

netCall, netPing, and netSend are all asynchronous functions that return a Promise, so they can take advantage of async/await-style programming. netSignal, however, is a synchronous function.

URNET Main Classes

The new modular version of URNET is written in Typescript and has three main classes:

  • NetEndpoint is the interface to URNET for both servers and clients
  • NetSocket is an abstraction used to represent a connection link between clients and servers
  • NetPacket is the message envelope that is passed through NetSockets

These three classes can be used with any connection that supports serialization of a javascript object. For example, here is the relevant fragment of the WebSocket Server on NodeJS (using the ws library):

const EP = new NetEndpoint();
EP.configAsServer('SRV03'); // hardcode arbitrary server address
const WSS = new WebSocketServer(options, () => {
  WSS.on('connection', (client_link, request) => {
    const send = pkt => client_link.send(pkt.serialize());
    const close = () => client_link.close();
    const onData = data => {
      const returnPkt = EP._ingestClientMessage(data, client_sock);
      if (returnPkt) client_link.send(returnPkt.serialize());
    };
    const client_sock = new NetSocket(client_link, { send, close, onData });
    // check if this is a new socket
    if (EP.isNewSocket(client_sock)) {
      EP.addClient(client_sock);
      const uaddr = client_sock.uaddr;
      LOG(`${uaddr} client connected`);
    }
    client_link.on('message', onData);
    ...
  });
});

And for comparison, this is the fragment establishing client connection, also using the NetEndpoint and NetSocket classes:

EP = new NetEndpoint();
SERVER_LINK = new WebSocket(wsURI);
SERVER_LINK.addEventListener('open', async () => {
  LOG(...PR('Connected to server'));
  const send = pkt => SERVER_LINK.send(pkt.serialize());
  const close = () => SERVER_LINK.close();
  const onData = event => EP._ingestServerMessage(event.data, client_sock);
  const client_sock = new NetSocket(SERVER_LINK, { send, close, onData });
  SERVER_LINK.addEventListener('message', onData);
  ...
}); 

In particular, these are the lines that are the bridge between the underlying socket tech and NetEndpoint:

  // WSS server managing connections 
  const send = pkt => client_link.send(pkt.serialize());
  const close = () => client_link.close();
  const onData = data => {
      const returnPkt = EP._ingestClientMessage(data, client_sock);
      if (returnPkt) client_link.send(returnPkt.serialize());
  };
  client_link.on('message', onData);

  // client using WebSocket SERVER_LINK
  const send = pkt => SERVER_LINK.send(pkt.serialize());
  const close = () => SERVER_LINK.close();
  const onData = event => EP._ingestServerMessage(event.data, client_sock);
  const client_sock = new NetSocket(SERVER_LINK, { send, close, onData });
  SERVER_LINK.addEventListener('message', onData);

Lastly, here is the example using Unix Domain Socket (UDS) version. Note that methods are slightly different (e.g. write instead of send) but the overall structure of the code is the same.

  // UDS server managing connections
  const send = pkt => client_link.write(pkt.serialize());
  const close = () => {};
  const onData = data => {
    const returnPkt = EP._ingestClientMessage(data, socket);
    if (returnPkt) client_link.write(returnPkt.serialize());
  };
  const socket = new NetSocket(client_link, { send, close, onData });
  client_link.on('data', onData);
  
  // UDS client
  const send = pkt => SERVER_LINK.write(pkt.serialize());
  const close = ()=>{};
  const onData = data => EP._ingestServerMessage(data, client_sock);
  const client_sock = new NetSocket(SERVER_LINK, { send, close, onData });
  SERVER_LINK.on('data', onData);

Note

The methods EP._ingestClientMessage and EP._ingestServerMessage are intentioned named to suggest they are mirror functions, as the direction of the send() and onData() functions depends on whether you're a server or a client. This can be really confusing without the naming patterns here.

NetSocket Wrapper Implementation

The NetSocket class is an abstraction of a connection object, consisting of three functions you provide:

  • send function - this sends data to the upstream host
  • close function - this closes the connection from your end
  • onData function - this sends data to your end

Depending on if you're writing a serer or a client, the directionality of these calls will be different and use slightly different interfaces.

Server Wrapper Implementation

Using a ws websocket server as an example, note how onData usesEP._ingestClientPacket to route incoming client messages into the Endpoint. The send function is used to put message data out back to the client.

WSS.on('connection', (client_link, request) => {
  const send = pkt => client_link.send(pkt.serialize());
  const onData = data => {
    const returnPkt = EP._ingestClientPacket(data, client_sock);
    if (returnPkt) client_link.send(returnPkt.serialize());
  };
  const close = () => client_link.close();
  //
  client_link.on('message', onData);
  const client_sock = new NetSocket(client_link, { send, onData, close });
  EP.addClient(client_sock);
  ...

Also notable is the EP.addClient() call, which adds the newly-created NetSocket to the managed list of connections.

Client Wrapper Implementation

Using the built-in WebSocket object, note the parallel implementation of onData using _ingestServerData into this client. For the client to send to the server, it uses the send function.

SERVER_LINK = new WebSocket(wss_url);
SERVER_LINK.addEventListener('open', async () => {
  let out = `Connected to ${wss_url}`;
  LOG(...PR(out));
  ui_AddChatLine(out);
  const send = pkt => SERVER_LINK.send(pkt.serialize());
  const onData = event => EP._ingestServerPacket(event.data, client_sock);
  const close = () => SERVER_LINK.close();
  //
  const client_sock = new NetSocket(SERVER_LINK, { send, onData, close });
  SERVER_LINK.addEventListener('message', onData);

Note

Browsers can also introduce transport issues. We had an issue with Chrome not automatically send close frames on reload, so we have to use window.addEventListener('beforeunload', () => EP.disconnectAsClient()) to force the close. This doesn't happen in Firefox.

Client Details: Authenticating, Registering, and Declaring

After connection is established, an URNET client has to authenticate to receive its official "URNet Address" aka UADDR. This is a unique number on the network that can change as webapps connect/disconnect to it. Authentication only happens once, and all other traffic is denied until authentication is accepted (or is turned off).

// 2. start client; EP handles the rest
const auth = { identity: 'my_voice_is_my_passport', secret: 'crypty' };
const resdata = await EP.connectAsClient(client_sock, auth);
if (DBG) LOG(...PR('EP.connectAsClient returned', resdata));
if (resdata.error) {
  console.error(resdata.error);
  resolve(false);
  return;
}

After authentication, there are currently two other special operation packets that can be sent:

  1. a webapp can register itself as a device name with other properties. This is a system for future expansion, allowing apps to declare their persistent identity on the network without being tied to a specific UADDR as those are subject to change. Currently it's not being used in this version of URSYS, but the Device Manager from GEMSTEP does use something similar.
  // 3. register client with server
  const info = { name: 'UDSClient', type: 'client' };
  const regdata = await EP.declareClientProperties(info);
  if (regdata.error) console.error(regdata.error);
  1. A webapp can declare dynamic properties to the network. The prime example of this are the list of network messages that it implements so the server can route requests to it.
EP.addMessageHandler('NET:CLIENT_TEST_CHAT', data => {});
EP.addMessageHandler('NET:CLIENT_TEST', data => {});
EP.addMessageHandler('NET:HOT_RELOAD_APP', data => {});
const dcldata = await EP.declareClientMessages();
if (dcldata.error) console.error(dcldata.error, dcldata)

Note

declareClientMessages90 is a convenience method that assembles the list of network messages defined in the Endpoint, then calls the internal _declareClientServices() call. The declaration block is intended to be expandable.

Appendix: Full Code Examples

Example of browser-side handling (taken from net/serve-http-app/client-http.ts)
/** create a client connection to the HTTP/WS server. Use await inside an async function */
function Connect(): Promise<boolean> {
  const { wss_url } = GetClientInfoFromWindowLocation(window.location);
  const promiseConnect = new Promise<boolean>(resolve => {
    SERVER_LINK = new WebSocket(wss_url);
    SERVER_LINK.addEventListener('open', async () => {
      let out = `Connected to ${wss_url}`;
      LOG(...PR(out));
      ui_AddChatLine(out);
      const send = pkt => SERVER_LINK.send(pkt.serialize());
      const onData = event => EP._ingestServerPacket(event.data, client_sock);
      const close = () => SERVER_LINK.close();
      const client_sock = new NetSocket(SERVER_LINK, { send, onData, close });
      SERVER_LINK.addEventListener('message', onData);
      SERVER_LINK.addEventListener('close', () => {
        out = `Server closed connection`;
        LOG(...PR(out));
        ui_AddChatLine(out);
        EP.disconnectAsClient();
      });
      // needed on chrome, which doesn't appear to send a websocket closeframe
      window.addEventListener('beforeunload', () => {
        EP.disconnectAsClient();
      });
      // 2. start client; EP handles the rest
      const auth = { identity: 'my_voice_is_my_passport', secret: 'crypty' };
      const resdata = await EP.connectAsClient(client_sock, auth);
      if (DBG) LOG(...PR('EP.connectAsClient returned', resdata));
      if (resdata.error) {
        console.error(resdata.error);
        resolve(false);
        return;
      }
      // 3. register client with server
      const info = { name: 'UDSClient', type: 'client' };
      const regdata = await EP.declareClientProperties(info);
      if (DBG) LOG(...PR('EP.declareClientProperties returned', regdata));
      if (regdata.error) {
        console.error(regdata.error);
        resolve(false);
        return;
      }
      // 4. save global uaddr
      EP_UADDR = EP.uaddr;
      resolve(true);
    }); // end createConnection
  });
  return promiseConnect;
}
Example of server-side handling (taken from net/serve-http-app/serve-http.mts)
/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/** Start the HTTP and WebSocket servers. The WebSocket server uses the same
 *  http server instance, which allows it to tunnel websocket traffic after
 *  the initial handshake. This allows nginx (if running) to proxy forward
 *  http traffic as https.
 */
async function Listen() {
  const { http_port, http_host, http_docs, wss_path } = HTTP_INFO;
  FILE.EnsureDir(FILE.AbsLocalPath(http_docs));

  // configure HTTP server
  APP = express();

  if (SHOW_INDEX) {
    // show index of the directory if SHOW_INDEX true
    APP.get('/', serveIndex(http_docs));
  } else {
    // handle /
    APP.get('/', (req, res) => {
      ServeAppIndex(req, res);
    });
  }
  // sneaky locations list (as app/list)
  APP.get('/list', (req, res) => {
    ServeProxyLocations(req, res);
  });

  // apply static files middleware
  APP.use(express.static(http_docs));

  /** START HTTP SERVER **/
  const proxy_url = await m_PromiseProxyURL(http_port);

  SERVER = APP.listen(http_port, http_host, () => {
    LOG.info(`HTTP AppServer started on http://${http_host}:${http_port}`);
    if (proxy_url) LOG.info(`HTTP AppServer is proxied from ${proxy_url}`);
  });
  /** START WEBSOCKET SERVER with EXISTING HTTP SERVER **/
  WSS = new WebSocketServer({
    server: SERVER,
    path: `/${wss_path}`, // requires leading slash
    clientTracking: true
  });
  LOG.info(`HTTP URNET WSS started on ws://${http_host}:${http_port}/${wss_path}`);
  WSS.on('connection', (client_link, request) => {
    const send = pkt => client_link.send(pkt.serialize());
    const onData = data => {
      const returnPkt = EP._ingestClientPacket(data, client_sock);
      if (returnPkt) client_link.send(returnPkt.serialize());
    };
    const close = () => client_link.close();
    const client_sock = new NetSocket(client_link, { send, onData, close });
    if (EP.isNewSocket(client_sock)) {
      EP.addClient(client_sock);
      const uaddr = client_sock.uaddr;
      LOG(`${uaddr} client connected`);
    }
    // handle incoming data and return on wire
    client_link.on('message', onData);
    client_link.on('end', () => {
      const uaddr = EP.removeClient(client_sock);
      LOG(`${uaddr} client 'end' disconnect`);
    });
    client_link.on('close', () => {
      const uaddr = EP.removeClient(client_sock);
      LOG(`${uaddr} client 'close' disconnect`);
    });
    client_link.on('error', err => {
      LOG.error(`.. socket error: ${err}`);
    });
  });
}
⚠️ **GitHub.com Fallback** ⚠️