10 min read

Introducing Transporter

by Daniel Nagy @danielnagydotme
Young otter at a coffee shop working on a computer, digital art

To celebrate the new year, I'm excited to announce Transporter! 🥳 Transporter is a project I started sometime in 2021 as a prototype for communicating with iframes in the browser and with webviews in React Native. After a hiatus, I decided to revisit Transporter. With the knowledge I gained from my prototype, I set out to rewrite Transporter to improve the original design.

Today I will introduce you to Transporter. By the end of this blog post, you should have a good idea of what Transporter is and how you might use it in your own projects. I will also cross-examine Transporter with two similar libraries that predate Transporter, one of which had an influence on the design of Transporter. Let's get started!

What is Transporter?#

Transporter is a library for typesafe distributed computing in TypeScript. Distributed computing refers to multiple processes, or multiple computers, working together to accomplish a single task.

One of the most primitive ways of communicating with other processes is by passing messages. Messages are sent from one process to another using a communication channel. A communication channel is often composed of many protocols, known as a protocol stack. Messages pass through each layer until they reach their destination.

Message passing is great, but as a tool for building typesafe and maintainable systems, it is not so great. Message passing lacks semantics and is a breeding ground for race conditions. That is why it is often necessary to provide structure on top of message passing to make it easier to reason about.

In a sense, that is exactly what Transporter is; it is structure and semantics on top of message passing. More specifically, Transporter makes it possible to call functions in other processes as if they were in-process. This is known as remote procedure call, or RPC for short. This eliminates the cognitive overhead associated with massage passing and enables higher-level programming between systems. You can write programs in a familiar procedural style without being aware that the work is being distributed among many processes.

Let's take a look at some of the design goals of Transporter.

Design Goals#

Typesafe#

Transporter was designed for TypeScript and therefore needed to provide type safety. Not only at the application level but also at the protocol level. I also wanted Transporter to be flexible, so it needed to support configurable subprotocols. These two goals seem to be at odds with each other, and, if I'm being honest, designing an API to meet these goals in TypeScript was probably the hardest part of building Transporter.

General Purpose#

I wanted Transporter to be a general-purpose tool that could be used for unknown purposes. After all, the motivation for the original prototype was to reuse fragments of UI in both the browser and React Native. Therefore, Transporter needed to easily integrate with any transport layer. Somewhat ironically, Transporter is transport layer agnostic.

INFO

Fun fact: the name Transporter was inspired by the action film The Transporter, staring Jason Statham.

Use the Language#

Transporter takes the stance that if you have a statically typed language and if you use it correctly, then you do not need schema builders or runtime typechecking. There is no tight coupling between your API and Transporter.

It Just Works#

I wanted Transporter to feel like it "just works" without weird edge cases. If it is valid JavaScript syntax, then it should just work. The exception to this is that you cannot enumerate the properties of a remote object.

No Globals#

When designing Transporter, I had to face the reality that there may be multiple instances of Transporter, or even multiple versions of Transporter, in a single runtime. Therefore, Transporter is designed to work in these sorts of environments without collision.

A First Look#

We'll now take a first look at some of the core concepts of Transporter. I will not be doing a deep dive on any of these concepts; rather, I will just scratch the surface to give you a basic understanding.

The Transporter Protocol#

The transporter protocol (lowercase) is an RPC message protocol. It is the outermost protocol in the protocol stack. The transporter protocol is type-agnostic and requires a subprotocol for type safety (at the protocol level).

Sessions#

Sessions are the most fundamental API provided by Transporter. A ServerSession is created whenever you want to provide a resource, and a ClientSession is created whenever you want to use a resource.

Sessions are both a message source and a message sink. A session takes messages as input and produces messages as output. A session's inlet is an Observer, and its outlet is an Observable. You can transmit these messages however you like; this makes the session transport layer agnostic.

Furthermore, sessions can spawn agents to manage resources. Each Agent is responsible for a single resource. Transporter has a feature called recursive RPC. If this feature is enabled, then a session will spawn agents as it receives new resources. Terminating a session will terminate all of its active agents. Sessions are observed by the root supervisor.

Subprotocols#

Subprotocols are necessary to provide type safety at the protocol level. They ensure your API is compatible with your transport layer. For example, if your data interchange format is JSON, then your API cannot use undefined because that is not valid JSON.

Subprotocols are also used to determine if recursive RPC is enabled or not. A subprotocol must be connection-oriented and bidirectional to enable recursive RPC.

Observables#

Observables are lazy push data structures that can emit values both synchronously and asynchronously. Transporter observables are based on ReactiveX and rxjs. Transporter observables should have interop with rxjs observables. Transporter operators do not necessarily behave the same as rxjs operators with the same name.

Transporter makes heavy use of observables internally. Observables are also the foundation for Pub/Sub in Transporter.

Recursive RPC#

Recursive RPC refers to the inclusion of functions or proxies in function IO. This is an interesting concept because it allows state between processes to be held on the call stack. To use recursive RPC, your subprotocol must be connection-oriented and bidirectional.

When using recursive RPC it is important to talk about resource management. When Transporter creates a proxy for a remote resource, it registers a callback function for when the proxy is disposed of using FinalizationRegistry. When the proxy is disposed of it sends a message to the server to free up the resource.

That rounds out the most fundamental aspects of Transporter. However, Transporter offers many more APIs for things like memoization, dependency injection, Pub/Sub, and so on. For complete API docs head over to Transporter's GitHub repo!

Cross-Examination#

I'll now do a brief comparison of Transporter with Comlink and tRPC.

When I first realized that RPC could be used in the browser to communicate with iframes, I turned to the internet in search of an existing library, which is when I found Comlink. My initial experience with Comlink was good. However, I soon discovered that Comlink wasn't what I had hoped, and its simple and charming interface had its limits.

I saw Comlink as a proof-of-concept, and it inspired me to start working on Transporter. Transporter tries to imitate some of the charm of Comlink while being much more robust and general purpose. There are many subtle differences between Comlink and Transporter, but let's quickly list out some of the major differences.

  1. Transporter is written in and designed for TypeScript. While Comlink does provide some TypeScript types, Transporter provides better type safety when using TypeScript.

  2. Comlink does not automatically proxy functions. Using Comlink, you must explicitly proxy a function by using the proxy higher-order function. On the other hand, Transporter automatically proxies functions. Because functions cannot be cloned, there is no ambiguity between cloning and proxying a function. While this may not sound like a big deal, it has real-world usability implications. For example, with Comlink, you cannot easily pass objects with deeply nested functions. You would need to recursively map the values of an object to wrap any functions.

  3. Comlink unfortunately does not always "just work." For example, trying to use a function prototype method, such as apply, on a proxied function does not work. Again, you might not think this is a big deal. However, if you transpile your JavaScript, the transpiler may unknowingly use function prototype methods when transpiling your code.

  4. Comlink does not provide any scoping mechanisms. Because of this, you cannot easily expose more than one value from a process. If a third-party dependency used Comlink, for example, that could cause problems with your application.

  5. Comlink was designed specifically to be used in the browser. This makes it challenging, but not impossible, to use Comlink outside the browser. To use Comlink for other transport layers, you would need to create a custom endpoint, which requires looking at the internals of Comlink.

These are just some of the differences between Comlink and Transporter. However, Transporter is a completely different library with its own opinions and API. For example, Transporter uses observables and does not have the concept of lazy promises like Comlink.

Comparison with tRPC#

Another popular RPC library for TypeScript is tRPC. To compare Transporter with tRPC, I think it's best to start with an example. I'll take the example found on tRPC's website, port it to Transporter, and do a side-by-side comparison.

Trpc Server
1
import { initTRPC } from '@trpc/server';
2
import { createHTTPServer } from '@trpc/server/adapters/standalone';
3
import { z } from 'zod';
4
import { db } from './db';
5
6
const t = initTRPC.create();
7
8
const appRouter = t.router({
9
userList: t.publicProcedure.query(async () => {
10
return await db.user.findMany();
11
}),
12
userById: t.publicProcedure
13
.input(z.string())
14
.query(async (opts) => {
15
const { input } = opts;
16
return await db.user.findById(input);
17
}),
18
userCreate: t.publicProcedure
19
.input(z.object({ name: z.string() }))
20
.mutation(async (opts) => {
21
const { input } = opts;
22
return await db.user.create(input);
23
}),
24
});
25
26
export type AppRouter = typeof appRouter;
27
28
const server = createHTTPServer({
29
router: appRouter,
30
});
31
32
server.listen(3000);
Transporter Server
1
import * as Bun from "bun";
2
import * as Message from "@daniel-nagy/transporter/Message";
3
import * as Observable from "@daniel-nagy/transporter/Observable";
4
import * as Session from "@daniel-nagy/transporter/Session";
5
import * as Subprotocol from "@daniel-nagy/transporter/Subprotocol";
6
import * as SuperJson from "@daniel-nagy/transporter/SuperJson";
7
import { db } from './db';
8
9
module Api {
10
export async function userList() {
11
return await db.user.findMany();
12
}
13
14
export async function userById(id: string) {
15
return await db.user.findById(id)
16
}
17
18
export async function userCreate(input: { name: string }) {
19
return await db.user.create(input);
20
}
21
}
22
23
export type Api = typeof Api;
24
25
const protocol = Subprotocol.init({
26
connectionMode: Subprotocol.ConnectionMode.ConnectionLess,
27
dataType: Subprotocol.DataType<SuperJson.t>(),
28
operationMode: Subprotocol.OperationMode.Unicast,
29
transmissionMode: Subprotocol.TransmissionMode.HalfDuplex
30
});
31
32
Bun.serve({
33
async fetch(req) {
34
using session = Session.server({ protocol, provide: Api });
35
const reply = Observable.firstValueFrom(session.output);
36
const message = SuperJson.fromJson(await req.json())
37
session.input.next(message as Message.t<SuperJson.t>);
38
39
return Response.json(SuperJson.toJson(await reply));
40
},
41
port: 3000
42
});

Comparing the two side-by-side, we can spot some differences right away. We notice that tRPC uses a router, whereas Transporter does not. Transporter does not need a router because objects can be composed arbitrarily to create namespaces. We also notice that tRPC uses a builder API, whereas Transporter does not. With Transporter, how you define your API is not strictly coupled to Transporter. This likely makes it easier to test your APIs because you can just call your functions directly without needing to involve Transporter. Transporter also supports generic functions, but using generic functions in tRPC likely isn't possible given its design.

If we examine tRPC a little closer, we can see that the semantics seem to resemble GraphQL. Transporter does not impose any such semantics. Looking at the documentation for tRPC, it seems to brand itself as an alternative to traditional BE API frameworks such as REST or GraphQL. Transporter tries to be more general purpose than that. For example, looking at the docs it is not obvious how you would use tRPC in the browser to communicate with workers.

Let's move on now and look at the client.

Trpc Client
1
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
2
import type { AppRouter } from '../server';
3
4
const trpc = createTRPCProxyClient<AppRouter>({
5
links: [
6
httpBatchLink({
7
url: 'http://localhost:3000',
8
}),
9
],
10
});
11
12
async function main() {
13
const users = await trpc.userList.query();
14
console.log('Users:', users);
15
16
const createdUser = await trpc.userCreate.mutate({ name: 'sachinraja' });
17
console.log('Created user:', createdUser);
18
19
const user = await trpc.userById.query('1');
20
console.log('User 1:', user);
21
}
22
23
main().catch(console.error);
Transporter Client
1
import * as Message from "@daniel-nagy/transporter/Message";
2
import * as Observable from "@daniel-nagy/transporter/Observable";
3
import * as Session from "@daniel-nagy/transporter/Session";
4
import * as Subprotocol from "@daniel-nagy/transporter/Subprotocol";
5
import * as SuperJson from "@daniel-nagy/transporter/SuperJson";
6
import type { Api } from '../server';
7
8
const client = createClient();
9
10
async function main() {
11
const users = await client.userList();
12
console.log('Users:', users);
13
14
const createdUser = await client.userCreate({ name: 'sachinraja' });
15
console.log('Created user:', createdUser);
16
17
const user = await client.userById('1');
18
console.log('User 1:', user);
19
}
20
21
main.catch(console.error);
22
23
function createClient() {
24
const protocol = Subprotocol.init({
25
connectionMode: Subprotocol.ConnectionMode.Connectionless,
26
dataType: Subprotocol.DataType<SuperJson.t>(),
27
operationMode: Subprotocol.OperationMode.Unicast,
28
transmissionMode: Subprotocol.TransmissionMode.HalfDuplex,
29
});
30
31
const session = Session.client({
32
protocol,
33
resource: Session.Resource<Api>(),
34
});
35
36
const toRequest = (message: string) =>
37
new Request("http://localhost:3000", {
38
body: message,
39
headers: {
40
"Content-Type": "application/json"
41
},
42
method: "POST",
43
});
44
45
session.output
46
.pipe(
47
Observable.map(SuperJson.toJson),
48
Observable.map(JSON.stringify),
49
Observable.map(toRequest),
50
Observable.flatMap(fetch),
51
Observable.flatMap(response => response.json()),
52
Observable.map(SuperJson.fromJson),
53
Observable.filter(Message.isMessage)
54
)
55
.subscribe(session.input);
56
57
return session.createProxy();
58
}

tRPC certainly has the advantage in keystrokes here. One thing I'd like to point out though is that Transporter did not actually provide an HTTP server or HTTP client, and yet we were able to get Transporter working with our own HTTP server (Bun.serve) and our own HTTP client (fetch) with just a little bit of extra code. We didn't need to look at the internals of Transporter to do it either. I could easily replace fetch with a different HTTP client or with a different protocol altogether if I wanted to.

Transporter is not a replacement for tRPC, nor does it try to be. Transporter has different goals and different features. For example, recursive RPC is likely a feature unique to Transporter that tRPC does not support.

That's probably enough for today. For more examples and API docs head over to Transporter's GitHub repo!

Happy New Year! 🎊

Written by Daniel Nagy

Daniel is a software engineer and full-stack web developer. He studied computer science at Ohio University and has been doing web development and hybrid mobile app development for over 8 years.

If you liked this post, then please consider donating or becoming a sponsor. Your support will help me produce more content that gives back to the community.

Buy me a coffeeBecome a sponsor

Comments#

INFO

You will not receive notifications for new comments. If you are waiting for a reply, please check back periodically.

Markdown enabled