Skip to content

Conversation

@joshmossas
Copy link
Member

@joshmossas joshmossas commented Jul 9, 2025

This PR introduces the concept of "TransportAdapters" and "TransportDispatchers".

  • TransportAdapter - Exists on the server. They adapt requests and messages coming from a transport such as websockets or http and transform them into a generic arri request.
  • TransportDispatcher - Exists on the client. They take in a generic arri request and transform them into a payload specific to the chosen transport.

With this change Arri essentially becomes a generic "Request > Response" system that can be adapted to any transport through use of these TransportAdapters and TransportDispatchers. This also means that Arri itself is no longer concerned with establishing or maintaining active connections this is all handled at the Adapter and Dispatcher layer.

Arri servers can have multiple TransportAdapters installed, and Arri clients can have multiple TransportDispatchers installed. Additionally transports can be enabled globally or at a "per-procedure" level. So for example you can have all procedures default to http and then enabled ws for specific procedures or vice versa.

Lastly this PR will ship with 1st party integrations for http and websockets. Documentation will be added in the future for people who want to make their own integrations to support custom transports such as tcp udp etc.

Examples

All examples are in Typescript, but the same concepts will apply to other languages.

Registering a TransportAdapter

import { a } from '@arrirpc/schema';
import { ArriApp, HttpAdapter, WsAdapter, defineRpc } from '@arrirpc/server';

const app = new App();
const http = new HttpAdapter({ port: 2020 });
app.use(http);

// this procedure is now available over standard HTTP
app.rpc("sayHello", defineRpc({
  params: a.object({ name: a.string() }),
  response: a.object({ message: a.string() }),
  handler({ params }) {
    return { message: `Hello ${params.name}` };
  }
});

export default app;

Register Multiple TransportAdapters

import { a } from '@arrirpc/schema';
import { ArriApp, HttpAdapter, WsAdapter, defineRpc } from '@arrirpc/server';

const app = new App({ defaultTransport: ["http"]}); // make http the default transport
const http = new HttpAdapter({ port: 2020 });
// WsAdapter requires something that allows it to register HTTP endpoints
// Which is why we are passing the HttpAdapter here.
const ws = new WsAdapter(http, { connectionPath: "/ws-handshake" });
app.use(http);
app.use(ws);

// this procedure uses the default specified in the app config
app.rpc("sayHello", defineRpc({
  params: a.object({ name: a.string() }),
  response: a.object({ message: a.string() }),
  handler({ params }) {
    return { message: `Hello ${params.name}` };
  }
});

app.rpc("sayGoodbye", defineRpc({
  // override the default and make this procedure
  // available over HTTP and Websockets
  transport: ["http", "ws"],
  params: a.object({ name: a.string() }),
  response: a.object({ message: a.string() }),
  handler({ params }) {
    return { message: `Goodbye ${params.name}` };
  }
});

export default app;

Client Usage

The Arri clients will autoinstall the HTTP and Websocket adapters depending on which transports are available by the server

const client = new Client({ 
  // this property will be required if the HTTP dispatcher is installed
  baseUrl: "https://example/com",
  // this property will be required if the WS dispatcher is installed
  wsConnectionUrl: "ws://example.com/ws-handshake" 
});

// will use whatever the available default transport is
// in this case it would be HTTP since that's the only transport available for this procedure
await client.sayHello({ name: "john doe" });

// manually specify you want to send over ws
// you can also globally configure the client to give default 
// priority to a specific transport
await client.sayHello({ name: "john doe" }, { transport: "ws" }) 

For custom transports you will have to manually install them when initializing the client.

const tcpDispatcher = new MyCustomTcpDispatcher({ 
  foo: "foo",
  bar: 2,
  baz: false,
});
const client = new Client({
  dispatchers: {
    "tcp": tcpDispatcher,
  }
});

Arri Messages

In order to enable this I've also created a generic Arri message format that can be encoded and decoded by Arri clients and servers. This allows developers to use the same exact encode/decode functions regardless of the transport being used. The only exception is when procedures sent over HTTP. In those cases Arri messages get converted to HTTP requests and responses.

Client Messages

ARRIRPC/0.0.8 sayHello
req-id: 1
content-type: application/json

{"name":"john doe"}
ARRIRPC/0.0.8 sayGoodbye
req-id: 2
content-type: application/json
some-custom-header: foo

{"name":"john doe"}

Server Messages

ARRIRPC/0.0.8 SUCCESS
req-id: 1
content-type: application/json

{"name":"hello john"}
ARRIRPC/0.0.8 FAILURE
req-id: 1
content-type: application/json

{"code":400,"message":"Invalid parameter [/name]. Expected string."}
ARRIRPC/0.0.8 HEARTBEAT
heartbeat-interval: 10000
ARRIRPC/0.0.8 CONNECTION_START
heartbeat-interval: 10000

Server Event Stream Messages

Start of an "event stream"

ARRIRPC/0.0.8 ES_START
req-id: 1
content-type: application/json

Received new event

ARRIRPC/0.0.8 ES_EVENT
req-id: 1
event-id: 15

{"message":"hello world"}

Event stream has event

ARRIRPC/0.0.8 ES_END
req-id: 1

Some notes

  • all headers are lowercase
  • header keys must only appear once
  • headers are all delimited by \n
  • the end of headers is indicated by \n\n
  • req-id is only unique to the client. it is used by the client to track which responses belong to which requests
  • application/json is the only supported content type at this time. Eventually we will add support for some kind of binary format (mostly likely CBOR or Messagepack)

List of reserved header keys:

  • req-id - a client unique identifier for a specific request
  • heartbeat-interval - how over the server will send a "heartbeat" message in milliseconds
  • content-type - the content type of the message body
  • client-version - comes from the app.info.version option. you can use this to see what api version you client was generated with

Any other header names will be considered as "custom headers". For example you can use a custom "authorization" header to pass an auth token similar to how you would in a standard HTTP api.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants