Remix.run Logo
kentonv 4 days ago

You can define a stream like:

    interface Stream extends RpcTarget {
      write(chunk): void;
      end(): void;
      [Symbol.dispose](): void;
    }
Note that the dispose method will be called automatically when the caller disposes the stub or when they disconnect the RPC session. The `end()` method is still useful as a way to distinguish a clean end vs. an abort.

In any case, you implement this interface, and pass it over the RPC connection. The other side can now call it back to write chunks. Voila, streaming.

That said, getting flow control right is a little tricky here: if you await every `write()`, you won't fully utilize the connection, but if you don't await, you might buffer excessively. You end up wanting to count the number of bytes that aren't acknowledged yet and hold off on further writes if it goes over some threshold. Cap'n Proto actually has built-in features for this, but Cap'n Web does not (yet).

Workers RPC actually supports sending `ReadableStream` and `WritableStream` (JavaScript types) over RPC. I'd like to support that in Cap'n Web, too, but haven't gotten around to it yet. It'd basically work exactly like above, but you get to use the standard types.

---------------------

Exceptions work exactly like you'd expect. If the callee throws an exception, it is serialized, passed back to the caller, and used to reject the promise. The error also propagates to all pipelined calls that derive from the call that threw.

---------------------

The mapper function receives, as its parameter, an `RpcPromise`. So you cannot actually inspect the value, you can only pipeline on it. `friend.isBestFriend ?` won't work, because `friend.isBestFriend` will resolve as another RpcPromise (for the future property). I suppose that'll be considered truthy by JavaScript, so the branch will always evaluate true. But if you're using TypeScript, note that the type system is fully aware that `friend` is type `RpcPromise<Friend>`, so hopefully that helps steer you away from doing any computation on it.

mythmon_ 4 days ago | parent | next [-]

I'll definitely be watching out for more built-in streaming support. Being able to throw the standard types directly over the wire and trust that the library will handle optimally utilizing the connection would make this the RPC library that I've been looking for all year.

ianbicking 4 days ago | parent | prev [-]

Re: RpcPromise, I'm pretty sure all logical operations will result in unexpected results. TypeScript isn't going to complain about using RpcPromise as a boolean.

Maybe the best solution is just an eslint plugin. Like this plugin basically warns for the same thing on another type: https://github.com/bensaufley/eslint-plugin-preact-signals

Overloading .map() does feel a bit too clever here, as it has this major difference from Array.map. I'd rather see it as .mapRemote() or something that immediately sticks out.

I can imagine a RpcPromise.filterRemote(func: (p: RPCPromise) => RPCPromise) that only allows filtering on the truthiness of properties; in that case the types really would save someone from confusion.

I guess if the output type of map was something like:

    type MapOutput = RpcPromise | MapOutput[] | Record<string, MapOutput>;
    map(func: (p: RpcPromise) => MapOutput)
... then you'd catch most cases, because there's no good reason to have any constant/literal value in the return value. Almost every case where there's a non-RpcPromise value is likely some case where a value was calculated in a way that won't work.

Though another case occurs to me that might not be caught by any of this:

    result = aPromise.map(friend => {...friend, nickname: getNickname(friend.id, userId)})
The spread operator is a pretty natural thing to use in this case, and it probably doesn't work on an RpcPromise?