10 ms·
Cap'n Web: a new RPC system for browsers and web servers
- beckford 1y agoSince Cap'n Web is a simplification of Cap'n Proto RPC, it would be amazing if eventually the simplification traveled back to all the languages that Cap'n Proto RPC supports (C++, etc.). Or at least could be made to be binary compatible. Regardless, this is great.
- kentonv 1y agoYeah I now want to go back and redesign the Cap'n Proto RPC protocol to be based on this new design, as it accomplishes all the same features with a lot less complexity! But it may be tough to justify when we already have working Cap'n Proto implementations speaking the existing protocol, that took a lot of work to build. Yes, the new implementations will be less work than the original, but it's still a lot of work that is essentially running-in-place. OTOH, it might make it easier for Cap'n Proto RPC to be implemented in more languages, which might be worth it... idk.
- beckford 1y agoDisclaimer: I took over maintenance of the Cap'n Proto C bindings a couple years ago. That makes sense. There is some opportunity though since the Cap'n Proto RPC had always lacked a JavaScript RPC implementation. For example, I had always been planning on using the Cap'n Proto OCaml implementation (which had full RPC) and using one of the two mature OCaml->JavaScript frameworks to get a JavaScript implementation. Long story short: Not now, but I'd be interested in seeing if Cap'n Web can be ported to OCaml. I suspect other language communities may be interested. Promise chaining is a killer feature and was (previously) difficult to implement. Aside: Promise chaining is quite undersold on your blog post; it is co-equal to capabilities in my estimation.
- josephg 1y agoI tried using the C library recently but was turned off by the lack of bounds checking. I’m not sure how anyone could reasonably accept packets over the wire which allow arbitrary memory access. Am I misunderstanding? Any hope this can be fixed?
- CobrastanJorji 1y agoYou mean redesign Cap'n Proto to not have a schema? Or did you mean the API, not the protocol?
- kentonv 1y agoHere is the Cap'n Proto RPC protocol: https://github.com/capnproto/capnproto/blob/v2/c%2B%2B/src/capnp/rpc.capnp https://github.com/capnproto/capnproto/blob/v2/c%2B%2B/src/c... That's just the RPC state machine -- the serialization is specified elsewhere, and the state machine is actually schema-agnostic. (Schemas are applied at the edges, when messages are actually received from the app or delivered to it.) This is the Cap'n Web protocol, including serialization details: https://github.com/cloudflare/capnweb/blob/main/protocol.md https://github.com/cloudflare/capnweb/blob/main/protocol.md Now, to be fair, Cap'n Proto has a lot of features that Cap'n Web doesn't have yet. But Cap'n Web's high-level design is actually a lot simpler. Among other things, I merged the concepts of call-return and promise-resolve. (Which, admittedly, CapTP was doing it that way before I even designed Cap'n Proto. It was a complete mistake on my part to turn them into two separate concepts in Cap'n Proto, but it seemed to make sense at the time.) What I'd like to do is go back and revise the Cap'n Proto protocol to use a similar design under the hood. This would make no visible difference to applications (they'd still use schemas), but the state machine would be much simpler, and easier to port to more languages.
- izzylan 1y agoI was trying to port Cap'n Proto to modern C# as a side project when I was unemployed, since the current implementation years old and new C# features have been released that would make it much nicer to use. I love the no-copy serialization and object capabilities, but wow, the RPC protocol is incredibly complex, it took me a while to wrap my head around it, and I often had to refer to the C++ implementation to really get it.
- davexunit 1y agoThis has some similarities and significant differences from OCapN [0]. Capability transfer and promise pipelining are part of both, and both are schemaless. Cap'n web lacks out-of-band capabilities, which OCapN has in the form of URIs known as sturdyrefs. I suppose this difference is why the examples show API key authentication since anyone can connect to the Cap'n Web endpoint. This is not necessary in OCapN because a sturdyref is an unguessable token so by possessing it you have the authority to send messages to the endpoint it designates. Cap'n Web also seems to lack the ability for Alice to introduce Bob to Carol, a feature in OCapN called third-party handoffs. Handoffs are needed for distributed applications. So I guess Cap'n Web is more for traditional client-server SaaS but now with a dash of ocaps. [0] https://ocapn.org/ https://ocapn.org/
- kentonv 1y agoI’d love to add 3PH support in the future, but it wasn’t a priority for an initial release as this is very focused on enabling browser<->web server communications specifically. SturdyRefs are tricky. My feeling is that they don’t really belong in the RPC protocol itself, because the mechanism by which you restore a SturdyRef is very dependent on the platform in which you're running. Cloudflare Workers, for example, may soon support storing capabilities into Durable Object storage. But the way this will work is very tied to the Cloudflare Workers platform. Sandstorm, similarly, had a persistent capability mechanism, but it only made sense inside Sandstorm – which is why I removed the whole notion of persistent capabilities from Cap’n Proto itself. The closest thing to a web standard for SturdyRefs is OAuth. I could imagine defining a mechanism for SturdyRefs based on OAuth refresh tokens, which would be pretty cool, but it probably wouldn’t actually be what you want inside a specific platform like Sandstorm or Workers.
- electric_muse 1y agoThis is such a useful pattern. I’ve ended up building similar things over and over again. For example, simplifying the worker-page connection in a browser or between chrome extension “background” scripts and content scripts. There’s a reason many prefer “npm install” on some simple sdk that just wraps an API. This also reminds me a lot of MCP, especially the bi-directional nature and capability focus.
- HDThoreaun 1y agoThis a reference to cap'n jazz?
- kentonv 1y agoNo, in fact this is the first I've heard of Cap'n Jazz. The name "Cap'n Proto" came from "capabilities and protobuf". The first, never-released version was based on Protobuf serialization. The first public release (way back on April 1, 2013) had its own, all-new serialization. There's also a pun with it being a "cerealization protocol" (Cap'n Cruch is a well-known brand of cereal).
- chrisweekly 1y agoaha, the Cap'n Crunch "cerealization" pun is solid
- benburton 1y agoMy first thought too. Glad there are other indie rock folks poking around!
- evansd 1y agoI need time to try this out for real, but the simplicity/power ratio here looks like it could be pretty extraordinary. Very exciting! Tiny remark for @kentonv if you're reading: it looks like you've got the wrong code sample immediately following the text "Putting it together, a code sequence like this".
- kentonv 1y agoUgh, looks like a copy-pasto when moving the blog into the CMS, will get that fixed, thanks. The code was supposed to be: let namePromise = api.getMyName(); let result = await api.hello(namePromise); console.log(result);
- fitzn 1y agoJust making sure I understand the "one round trip" point. If the client has chained 3 calls together, that still requires 3 messages sent from the client to the server. Correct? That is, the client is not packaging up all its logic and sending a single blob that describes the fully-chained logic to the server on its initial request. Right? When I first read it, I was thinking it meant 1 client message and 1 server response. But I think "one round trip" more or less message "1 server message in response to potentially many client messages". That's a fair use of "1 RTT", but took me a moment to understand. Just to make that distinction clear from a different angle, suppose the client were _really_ _really_ slow and it did not send the second promise message to the server until AFTER the server had computed the result for promise1. Would the server have already responded to the client with the result? That would be a way to incur multiple RTTs, albeit the application wouldn't care since it's bottlenecked by the client CPU, not the network in this case. I realize this is unlikely. I'm just using it to elucidate the system-level guarantee for my understanding. As always, thanks for sharing this, Kenton!
- benpacker 1y agoMy understanding is that your first read is right and your current understanding is wrong. The client sends over separate 3 calls in one message, or one message describing some computation (run this function with the result of this function) and the server responds with one payload.
- Elucalidavah 1y ago> the client is not packaging up all its logic and sending a single blob that describes the fully-chained logic to the server on its initial request. Right See "But how do we solve arrays" part: > > .map() is special. It does not send JavaScript code to the server, but it does send something like "code", restricted to a domain-specific, non-Turing-complete language. The "code" is a list of instructions that the server should carry out for each member of the array
- kentonv 1y agoTo chain three calls, the client will send three messages, yes. (At least when using the WebSocket transport. With the HTTP batch transport, the entire batch is concatenated into one HTTP request body.) But the client can send all three messages back-to-back without waiting for any replies from the server. In terms of network communications, it's effectively the same as sending one message.
- benpacker 1y agoThis seems great and I'm really excited to try it in place of trpc/orpc. Although it seems to solve one of the problems that GraphQL solved that trpc doesn't (the ability to request nested information from items in a list or properties of an object without changes to server side code), there is no included solution for the server side problem that creates that the data loader pattern was intended to solve, where a naive GraphQL server implementation makes a database query per item in a list. Until the server side tooling for this matures and has equivalents for the dataloader pattern, persisted/allowlist queries, etc., I'll probably only use this for server <-> server (worker <-> worker) or client <-> iframe communication and keep my client <-> server communication alongside more pre-defined boundaries.
- kentonv 1y agoI generally agree that the .map() trick doesn't actually replace GraphQL without some sort of server-side optimizations to avoid turning this into N+1 selects. However, if your database is sqlite in a Cloudflare Durable Object, and the RPC protocol is talking directly to it, then N+1 selects are actually just fine. https://www.sqlite.org/np1queryprob.html https://www.sqlite.org/np1queryprob.html
- qcnguy 1y agoI wonder if there's a way to process the RPC encoding inside a stored procedure, using the various JS-in-DB features out there.
- ryanrasti 1y agoAgree, and to add, from what I see, the main issue is that server-side data frameworks (e.g., ORMs) aren't generally built for the combination of security & composability that make them naturally synergize with Cap'n Web. Another way to put it: promise pipelining is a killer feature but if your ORM doesn't support pipelining, then you have to build a complex bridge to support them both. I've been working on this issue from the other side. Specifically, a TS ORM that has the level of composability to make promise pipelining a killer feature out of the box. And analagous to Cap'n Web's use of classes, it even models tables as classes with methods that return composable SQL expressions. If curious: https://typegres.com/play/ https://typegres.com/play/
- crabmusket 1y agoReally nice to have something I could potentially use across the whole app. I've been looking into things I can use over HTTP, websockets, and also over message channels to web workers. I've usually ended up implementing something that rounds to JSON-RPC (i.e. just use an `id` per request and response to tie them together). But this looks much sturdier. Building an operation description from the callback inside the `map` is wild. Does that add much in the way of restrictions programmers need to be careful of? I could imagine branching inside that closure, for example, could make things awkward. Reminiscent of the React hook rules.
- kentonv 1y agoThe .map() callback receives as its input an RpcPromise, not the actual value. You can't do any computation (including branching) on an RpcPromise, the only thing you can do is pipeline on it. Since the map callback must be synchronous, you can't await the promise either. So it turns out it's actually not easy to mess up in a map callback. The main thing you have to avoid is side effects that modify stuff outside the callback. If you do that, the effect you'll see is those modifications only get applied once, rather than N times. And any stubs you exfiltrate from the callback simply won't work if called later.
- crabmusket 1y agoYeah that's what I meant, reading/writing variables captured into the callback. But that sounds like the kind of code that would be easy to sniff out in a code review, or write lints for.
- unshavedyak 1y agoWhat would this look like for other language backends to support? Eg would be neat if Rust (my webservers) could support this on the backend edit: Downvoted, is this a bad question? The title is generically "web servers", obviously the content of the post focuses primarily on TypeScript, but i'm trying to determine if there's something unique about this that means it cannot be implemented in other languages. The serverside DSL execution could be difficult to impl, but as it's not strictly JavaScript i imagine it's not impossible?
- kentonv 1y agoI'm hoping the answer will be: * Use Cap'n Proto in your Rust backend. This is what you want in a type-safe language like Rust: generated code based on a well-defined schema. * We'll build some sort of proxy that, given a Cap'n Proto schema, converts between Cap'n Web and Cap'n Proto. So your frontend can speak Cap'n Web. But this proxy is just an idea for now. No idea if or when it'll exist.
- indolering 1y agoDo you feel like we are reaching the final form for RPC protocols yet?
- bryanlarsen 1y agoNow you've likely been downvoted because you're complaining about downvotes. Which is too bad because you've elicited a great answer from the author. It's usually best to ignore downvotes. Downvoted comments are noticeably grey. If people feel that's unfair, that'll attract upvotes in my experience.
- unshavedyak 1y ago> Now you've likely been downvoted because you're complaining about downvotes. Fwiw i think it was only once, and i was upvoted after mentioning it. You're right i could have worded it as something more ambiguous, aka "it seems this is unpopular" or w/e, but my edit was in reply to someones feedback (the downvote), so i usually mention it. No complaint, just a form of wordless-feedback that i was attempting to respond to. Despite such actions being against HN will heh.
- krosaen 1y agoThis looks pretty awesome, and excited it's not only a cloudflare product (Cap'n Web exists alongside cloudflare Workers). Reading this section [1], can you say more about: > as of this writing, the feature set is not exactly the same between the two. We aim to fix this over time, by adding missing features to both sides until they match. do you think once the two reach parity, that that parity will remain, or more likely that Cap'n Web will trail cloudflare workers, and if so, by what length of time? [1] https://github.com/cloudflare/capnweb/tree/main?tab=readme-ov-file#cloudflare-workers-rpc-interoperability https://github.com/cloudflare/capnweb/tree/main?tab=readme-o...
- kentonv 1y agoI think we'll likely keep them pretty close to in-sync, at least when it comes to features that make sense in both. If anything I'd expect Cap'n Web to run ahead of Workers RPC (as it is already doing, with the new pipeline features) because Cap'n Web's implementation is actually much simpler than Workers'. Cap'n Web will probably be the place where we experiment with new features.
- random3 1y agoIt's inspired by and created by a coauthor of [Cap'n Proto](https://capnproto.org https://capnproto.org), which is also what OCapN (referenced in a separate comment) name refers to. Cap'n Proto is inspired by ProtoBuf, protobuf has gRPC and gRPC web. We've been using ProtoBuf/gRPC/gRPC-web both in the backends and for public endpoints powering React / TS UI's, at my last startup. It worked great, particularly with the GCP Kubernetes infrastructure. Basically both API and operational aspects were non-problems. However, navigating the dumpster fire around protobuf, gRPC, gRPC web with the lack of community leadership from Google was a clusterfuck. This said, I'm a bit at loss with the meaning of schemaless. You can have different approaches wrt schema (see Avro vs ProtoBuf) but otherwise, can't fundamentally eschew schema/types. It's purely information tied to a communication channel that needs to be somewhere, whether that's explicit, implicit, handled by the RCP layer, passed to the type system, or worse all the way to the user/dev. Moreover, schemas tend to evolve and any protocol needs to take that into account. Historically, ProtoBuf has done a good job managing various tradeoffs, here but had no experience using Capt'n Proto, yet seen mostly good stuff about it, so perhaps I'm just missing something here.
- chrisweekly 1y ago100% agreed (as will anyone sane who's tried to use it), grpc-web is a trainwreck.
- kentonv 1y agoOf course, all programming language APIs even in dynamic languages have some implied type (aka schema). You can't write code against an API without knowing what methods it provides, what their inputs and outputs are, etc. -- and that's a schema, whether or not it's actually written out as such. But Cap'n Web itself does not need to know about any of that. Cap'n Web just accepts whatever method call you make, sends it to the other end of the connection, and attempts to deliver it. The protocol itself has no idea if your invocation is valid or not. That's what I mean by "schemaless" -- you don't need to tell Cap'n Web about any schemas. With that said, I strongly recommend using TypeScript with Cap'n Web. As always, TypeScript schemas are used for build-time type checking, but are then erased before runtime. So Cap'n Web at runtime doesn't know anything about your TypeScript types.
- divan 1y ago> RPC is often accused of committing many of the fallacies of distributed computing. > But this reputation is outdated. When RPC was first invented some 40 years ago, async programming barely existed. We did not have Promises, much less async and await. I'm confused. How is this a "protocol" if its core premises rely on very specific implementation of concurrency in a very specific language?
- kentonv 1y agoWhat do you mean? Async programming exists in tons of languages. Just off the top of my head, I've used async/await in JavaScript, C++, Python, Rust, C#, ... Anyway, the point here is that early RPC systems worked by blocking the calling thread while performing the network request, which was obviously a terrible idea.
- chao- 1y agoReminds me of the old "MongoDB is Web Scale" series of comedy videos: https://youtu.be/bzkRVzciAZg https://youtu.be/bzkRVzciAZg Some friends and I still jokingly troll each other in the vein of these, interjecting with "When async programming was discovered in 2008...", or "When memory safe compiled languages were invented in 2012..." and so forth.
- afiori 1y agoAsync/await became ergonomic and widespread only recently, I am sure there were async systems in the '80 but for example nodejs focus on non blocking I/O changed how a lot of people thought about servers and concurrency (whether node was first is almost irrelevant) Often when something is discovered or invented is far less influential[1] than when it jumps on and hype train. [1] the discovery is very important for historical and epistemological reasons of course, rewriting the past is bad
- frollogaston 1y agoIt's not a programming paradigm shift, more of a change to how runtimes work. We want to avoid the overhead of kernel threads in servers, and async/await on top of an event loop is a convenient way to do that, like in JS, Rust, and now Python. Meanwhile Go doesn't have async/await and never will because it doesn't need it; it does greenthreading instead. Java has that too now. Either way, your code waits on IO like before and does other work while it waits. But instead of the kernel doing the context switching, your runtime does something analogous at a higher layer.
- vmg12 1y agoI see that it supports websockets for the transport layer, is there any support for two way communication? edit: was skimming the github repo https://github.com/cloudflare/capnweb/tree/main?tab=readme-ov-file#cloudflare-workers-rpc-interoperability https://github.com/cloudflare/capnweb/tree/main?tab=readme-o... and saw this which answers my question: > Supports passing functions by reference: If you pass a function over RPC, the recipient receives a "stub". When they call the stub, they actually make an RPC back to you, invoking the function where it was created. This is how bidirectional calling happens: the client passes a callback to the server, and then the server can call it later. > Similarly, supports passing objects by reference: If a class extends the special marker type RpcTarget, then instances of that class are passed by reference, with method calls calling back to the location where the object was created. Gonna skim some more to see if i can find some example code.
- pests 1y agoIt seems peers are equal and there is no “server” or “client” role, just what you import/export from each.
- jimmyl02 1y agoThis seems like a similar and more feature complete / polished version of JSON RPC? The part that's most exciting to me is actually the bidirectional calling. Having set this up before via JSON RPC / custom protocol the experience was super "messy" and I'm looking forward to a framework making it all better. Can't wait to try it out!
- kentonv 1y agoYeah, JSON RPC doesn't support the pass-by-reference and lifecycle management stuff. You just have a static list of top-level functions you can call. This makes a pretty big difference in what kinds of APIs you can express. OTOH, JSON RPC is extremely simple. Cap'n Web is a relatively complicated and subtle underlying protocol.
- crabmusket 1y ago> You just have a static list of top-level functions you can call. Actually the author of JSON RPC suggested that method names could be dynamic, there's nothing in the spec preventing that. https://groups.google.com/g/json-rpc/c/vOFAhPs_Caw/m/QYdeSp014qUJ https://groups.google.com/g/json-rpc/c/vOFAhPs_Caw/m/QYdeSp0... So you could definitely build a cursed object/reference system by packing stuff into method names if you wanted. I doubt any implementations would allow this. But yes, JSON RPC is very minimal and doesn't really offer much.
- kentonv 1y agoRight, your methods in JSON RPC could be dynamic. JSON RPC really doesn't specify anything, so you can do anything with it. But you need conventions around that, like how does a client find out that the server exported new methods, and how does the client indicate that it is done with them? That's exactly what Cap'n Web is all about -- defining those conventions in a usable way.
- cbarrick 1y agoWhat's going on under the hood with that authentication example? Is the server holding onto some state in memory that this specific client has already authenticated? Or is the API key somehow stored in the new AuthenticatedSession stub on the client side and included in subsequent requests? Or is it something else entirely?
- kentonv 1y agoThe server constructs a new AuthenticatedSession implementation each time authenticate() is called, and can store the key (or just the authenticated user info) in the server-side object. This does mean the server is holding onto state, but remember the state only lasts for the lifetime of the particular connection. (In HTTP batch mode, it's only for the one batch. In WebSocket mode, it's for the lifetime of the WebSocket.)
- cbarrick 1y agoAh, the bit about it only lasting for the lifetime of the connection was the part I missed. That makes a lot of sense. As does the bit about the state staying on the server side. Thanks for the explanation!
- dimal 1y agoLooks very cool, especially passing functions back and forth. But then I wonder, what would I actually use that for? You mention that it’s schemaless as if that’s a good thing. Having a well defined schema is one of the things I like about tRPC and zod. Is there some way that you get the benefits of a schema with less work?
- kentonv 1y agoYou can use TypeScript to define your API, and get all the benefits of schemas. Well, except you don't get runtime type checking with TypeScript, which might be something you really want over RPC. For now I actually suggest using zod for type checks, but my dream is to auto-generate type checks based on the TypeScript types...
- ngrilly 1y agoThen that means we have to define the interface twice: once in TypeScript and another in Zod?
- kentonv 1y agoNo. Zod gives you TypeScript types corresponding to the schema. So you would only need to write the schema in Zod. (I do wish it could be the other way, though: Write only TypeScript, get runtime checks automatically.)
- ngrilly 1y agoI'm familiar with zod.infer but I'm not sure how to use it to produce an interface that would be compatible with RpcStub and RpcTarget, like MyApi in the example in your post: // Shared interface declaration: interface MyApi { hello(name: string): Promise<string>; } // On the client: let api: RpcStub<MyApi> = newWebSocketRpcSession("wss://example.com/api"); // On the server: class MyApiServer extends RpcTarget implements MyApi { hello(name) { return `Hello, ${name}!` } }
- spankalee 1y agoLooking at this quickly, it does seem to require (or strongly encourage?) a stateful server to hold on to the import and export tables and the state of objects in each. One thing about a traditional RPC system where every call is top-level and you pass keys and such on every call is that multiple calls in a sequence can usually land on different servers and work fine. Is there a way to serialize and store the import/export tables to a database so you can do the same here, or do you really need something like server affinity or Durable Objects?
- kentonv 1y agoThe state only lives for a single RPC session. When using WebSockets, that's the lifetime of the WebSocket. But when using the HTTP batch transport, a session is a single HTTP request, that performs a batch of calls all at once. So there's actually no need to hold state across multiple HTTP requests or connections, at least as far as Cap'n Web is concerned. This does imply that you shouldn't design a protocol where it would be catastrophic if the session suddenly disconnected in the middle and you lost all your capabilities. It should be possible to reconnect and reconstruct them.
- doctorpangloss 1y ago^ maybe the most knowledgable person in the world about these gritty details RPC SDKs should have session management, otherwise you end up in this situation: "Any sufficiently complicated gRPC or Cap'n'Proto program contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Akka"
- benmmurphy 1y agoare there security issues with no schemas + callback stubs + language on the server with little typing. for example with this `hello(name)` example the server expects a string but can the client pass an callback object that is string-like and then use this to try and trick the server into doing something bad?
- kentonv 1y agoThe protocol explicitly blocks overriding `toString()` (and all other Object.prototype members), as well as `toJSON()`, to prevent the obvious ways that you might accidentally invoke a callback when you weren't expecting to. How else might you invoke a callback by accident? That said, type checking is called out both in the blog post (in the section on TypeScript) and in the readme (under "Security Considerations"). You probably should use some runtime type checking library, just like you should with traditional JSON inputs. In the future I'm hoping someone comes up with a way to auto-generate type checks based on TypeScript types.
- benmmurphy 1y agoi was thinking if you were doing some string operations like `indexOf` then maybe that could be an issue.
- jauntywundrkind 1y agoI really dig the flexibility of transport. Having something that works over postMessage is totally clutch!! > Similarly, supports passing objects by reference: If a class extends the special marker type RpcTarget, then instances of that class are passed by reference, with method calls calling back to the location where the object was created. Can this be relaxed? Having to design the object model ahead of time for RpcTarget is constraining. If we could just attach a ThingClass.prototype[Symbol.for('RpcTarget')] = true then there would be a lot more flexibility, less need to design explciitly for RpcTarget, to use RpcTarget with the objects/classes of 3rd party libraries.
- kentonv 1y agoThe fear here is that if a class wasn't explicitly designed to be an RPC interface, then it could very easily offer functionality that isn't safe to expose over a security boundary with RPC. Normally, JavaScript classes do not expect their APIs to be security boundaries. With that said, I do think we ought to support `new RpcStub(myObject)` to explicitly create a stub around an arbitrary class, even if it doesn't extend `RpcTarget`. It would be up to the person writing the `new RpcStub` invocation to verify it's safe.
- mtrovo 1y agoThe section on how they solved arrays is fascinating and terrifying at the same time https://blog.cloudflare.com/capnweb-javascript-rpc-library/#but-how-do-we-solve-arrays https://blog.cloudflare.com/capnweb-javascript-rpc-library/#.... > .map() is special. It does not send JavaScript code to the server, but it does send something like "code", restricted to a domain-specific, non-Turing-complete language. The "code" is a list of instructions that the server should carry out for each member of the array. > But the application code just specified a JavaScript method. How on Earth could we convert this into the narrow DSL? The answer is record-replay: On the client side, we execute the callback once, passing in a special placeholder value. The parameter behaves like an RPC promise. However, the callback is required to be synchronous, so it cannot actually await this promise. The only thing it can do is use promise pipelining to make pipelined calls. These calls are intercepted by the implementation and recorded as instructions, which can then be sent to the server, where they can be replayed as needed.
- spankalee 1y agoI presume conditionals are banned - sort of like the rules of hooks - but how?
- kentonv 1y agoThe input to the map function (when it is called in "record" mode on the client) is an RpcPromise for the eventual value. That means you can't actually inspect the value, you can only queue pipelined calls on it. Since you can't inspect the value, you can't do any computation or branching on it. So any computation and branching you do perform must necessarily have the same result every time the function runs, and so can simply be recorded and replayed. The only catch is your function needs to have no side effects (other than calling RPC methods). There are a lot of systems out there that have similar restrictions.
- tonyg 1y agoIs .map specialcased or do user functions accepting callbacks work the same way? Because you could do the Scott-Mogensen thing of #ifTrue:ifFalse: if so, dualizing the control-flow decision making, offering a menu of choices/continuations.
- ianbicking 1y agoCouple random thoughts: I'm trying to see if there's something specifically for streaming/generators. I don't think so? Of course you can use callbacks, but you have to implement your own sentinel to mark the end, and other little corner cases. It seems like you can create a callback to an anonymous function, but then the garbage collector probably can't collect that function? --- I don't see anything about exceptions (though Error objects can be passed through). --- Looking at array mapping: https://blog.cloudflare.com/capnweb-javascript-rpc-library/#but-how-do-we-solve-arrays https://blog.cloudflare.com/capnweb-javascript-rpc-library/#... I get how it works: remotePromise.map(callback) will invoke the callback to see how it behaves, then make it behave similarly on the server. But it seems awfully fragile... I am assuming something like this would fail (in this case probably silently losing the conditional): friendsPromise.map(friend => {friend, lastStatus: friend.isBestFriend ? api.getStatus(friend.id) : null}) --- The array escape is clever and compact: https://blog.cloudflare.com/capnweb-javascript-rpc-library/#json-based-serialization https://blog.cloudflare.com/capnweb-javascript-rpc-library/#... --- I think the biggest question I have is: how would I apply this to my boring stateless-HTTP server? I can imagine something where there's a worker that's fairly simple and neutral that the browser connects to, and proxies to my server. But then my server can also get callbacks that it can use to connect back to the browser, and put those callbacks (capability?) into a database or something. Then it can connect to a worker (maybe?) and do server-initiated communication. But that's only good for a session. It has to be rebuilt when the browser network connection is interrupted, or if the browser page is reloaded. I can imagine building that on top of Cap'n Web, but it feels very complicated and I can equally imagine lots of headaches.
- kentonv 1y agoYou 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.
- tanepiper 1y agoReminds me of dnode (2015) - https://www.npmjs.com/package/dnode https://www.npmjs.com/package/dnode
- Yoric 1y agoThat's more or less a dynamically-typed version of what we had with Opalang ~15 years ago and it worked great. Happy to see that someone else has picked the idea of sending capabilities, including server->client calls!
- cyberax 1y agoETOOMAGIC for me.
- mrbluecoat 1y ago> "Cap'n" is short for "capabilities and" Learn something new every day
- pzo 1y agoAny idea how its compares to tRPC and oRPC? Wish all such project always had section of 'Why' and explained why it was needed and what it solved that other projects didn't.
- kentonv 1y agoI haven't actually used tRPC nor oRPC. But my understanding is that neither of them support object-capabilities nor promise pipelining. These are the killer features of Cap'n Web (and Cap'n Proto), which the blog post describes at length.
- quatonion 1y agoReally not a big fan of batteries included opinionated protocols. Even Cap'n Proto and Protobuf is too much for me. My particular favorite is this. But then I'm biased coz I wrote it haha. https://github.com/Foundation42/libtuple https://github.com/Foundation42/libtuple No, but seriously, it has some really nice properties. You can embed JSON like maps, arrays and S-Expressions recursively. It doesn't care. You can stream it incrementally or use it a message framed form. And the nicest thing is that the encoding is lexicographically sortable.
- elijahcarrel 1y agoThat link is dead
- matlin 1y agoMore niche use case but this would be awesome for communicating between contexts in web app (e.g. web worker, iframe, etc)
- kentonv 1y agoThat's a use case I'm actually already using it for. :) That's why the MessagePort transport is included.
- losvedir 1y agoBabe get in here, a new kentonv library just dropped! I'm surprised how little code is actually involved here, just looking at the linked GitHub repo. Is that really all there is to it? In theory, it shouldn't be too hard to port the server side to another language, right? I'm interested in using it in an Elixir server for a JS/TS frontend. For that matter, the language porting seems like a pretty good LLM task. Did you use much LLM-generated code for this repo? I seem to recall kentonv doing an entirely AI-generated (though human-reviewed, of course) proof of concept a few months ago.
- kentonv 1y agoSome of the tests are LLM-generated, but none of the library itself is. I don't think LLMs would be capable of writing this library (at least at present). The pieces fit together like a very intricate puzzle. I spent a lot more time thinking about how to do it right, than actually coding. Very different from my workers-oauth-provider library, where it was just implementing a well-known spec with a novel (yet straightforward) API. The code might port nicely to another dynamic language, like Python, but I think you'd have a hard time porting it to a statically-typed language. There's a whole lot of iterating over arbitrary objects without knowing their types.
- krosaen 1y agoHammock driven development :) https://www.youtube.com/watch?v=f84n5oFoZBc https://www.youtube.com/watch?v=f84n5oFoZBc
- naasking 1y ago> There's a whole lot of iterating over arbitrary objects without knowing their types. That's just parametric polymorphism.
- nl 1y ago> just parametric polymorphism Those three words are doing a lot of work there.
- 1y ago
- spullara 1y agoThe spiritual descendant of Java RMI.
- yayitswei 1y agoOne of the authors is the Kenton who built this awesome lan party house: https://lanparty.house/ https://lanparty.house/
- meindnoch 1y agoWhat happens if I pass a recursive function to map()? let traverse = (dir) => { name: dir.name, files: api.ls(dir).map(traverse) // api.ls() returns [] for files }; let tree = api.ls("/").map(traverse);
- kentonv 1y agoI believe this will stack-overflow on the client side. The callback is invoked in recording mode synchronously when you call `.map()`. Nested maps are allowed, but this case ends up being infinitely nested, so eventually you're going to hit a stack overflow while trying to do the recording.
- meindnoch 1y agoBut you could detect such recursion and stop descending on the client side. Then the server could mirror the same recursion on their end.
- kentonv 1y agoYes, perhaps. Particularly if it's the exact same function (by identity). It hadn't occurred to me.
- comex 1y agoWhat prevents an attacker from using nested maps to make the server spend exponential amounts of CPU and memory on the response? Is there some kind of limit on the total number of response items?
- kentonv 1y agoThe application should track resource use and implement limits as needed. I know that sounds like a cop-out, but this is really true of any protocol, and the RPC protocol itself has no real knowledge of the cost of each operation or how much memory is held, so can't really enforce limits automatically.
- garaetjjte 1y agoI think Cap'n Proto plays with web platform pretty nicely too... okay, some might say that my webapp that is mostly written in C++, compiled with Emscripten and talks to server with capnp rpc-over-websocket is in fact not playing nice with web.
- mdasen 1y agoThis looks really nice. Are there plans to bring support to languages other than JS/TS?
- nly 1y agoLast time I used capnProto for RPC I found it an incredibly chatty protocol with tonnes of small memory allocations.
- stavros 1y agoI was really excited when I saw the headline, but I was kind of disappointed to see it doesn't support schemas natively. I know you can specify them via zod, but I'm really not looking forward to any more untyped APIs, given that the lack of strict API typing has been by far the #1 reason for the bugs I've had to deal with in my career.
- kentonv 1y agoI really want to bake in some sort of support for generating type checks based on TypeScript types... so then your schemas are just TypeScript. Not sure why this doesn't seem to be a common practice TBH, might be missing something.
- stavros 1y agoThat would be great, would these checks run at deserialization time? They'd probably need to, as you wouldn't want to assume that the stuff coming through the network is of a specific type.
- kentonv 1y agoI'm thinking the ideal would be if I could feed in a TypeScript interface, and have some tool generate a wrapper around that interface which type-checks all inputs. This tool could actually be totally independent from the RPC implementation. I don't think it's necessary to bake type checks into the deserialization itself, since the RPC system already doesn't make any assumptions about the payloads it is moving around (other than that they are composed of only the types that the deserialization supports, which is a fixed list).
- stavros 1y agoI understand your reasoning, but this is one of those things where people will use the default, and your choice as designer is whether you'll have the default that makes it harder to get started with, but reduces bugs down the line, or whether you'll make it easier to get started with, but much more buggy. History has shown that, if you want things to be popular, you should choose the latter, but I think the tide has turned enough that the former could be the right choice now. That's also the reason why we use Typescript instead of JS, so mandatory static typing would definitely fit with the zeitgeist. Honestly, the boundary is the one place where I wouldn't want to not have types.
- waynenilsen 1y agoNow do the same for ffi
- indolering 1y agoI find the choice of TypeScript to be disappointing. One of the reasons that capproto has struggled for market share is the lack of implementations. Is the overhead for calling into WASM too high for a Rust implementation to be feasible? A Haxe or Dafny implementation have let us generate libraries in multiple languages from the same source.
- josephg 1y agoThere’s no way you could implement this in 10kb of rust. It takes massive advantage of javascript’s dynamism to work. Also, I’m pretty sure ts / js are vastly more popular languages than rust. I suspect this will get a lot more use because it’s typescript.
- kentonv 1y agoYes, pretty much. Just interfacing between the JS app and the Wasm RPC implementation would take more code than the entire RPC implementation in TypeScript. Also, the audience of this library is very specifically TypeScript developers. If your app is Rust, you'd probably be happier with Cap'n Proto.
- ryanrasti 1y ago> I find the choice of TypeScript to be disappointing. Genuinely curious, is the disappointment because it's limited to the JS/TS ecosystem? My take is that by going all-in on TypeScript, they get a huge advantage: they can skip a separate schema language and use pure TS interfaces as the source of truth for the API. The moment they need to support multiple languages, they need to introduce a new complex layer (like Protobuf), which forces design into a "lowest common denominator" and loses the advanced TypeScript features that make the approach so powerful in the first place.
- indolering 1y agoYou can generate TypeScript schema for Haxe JS output. I'm honestly a bit surprised that TS isn't a supported target! That could change with some investments. Haxe is a great toolkit to develop libraries in because it reduces the overhead for each implementation. It would be nice to see some commercial entity invest in Haxe or Dafny (which can also enable verification of the reference implementation). > The moment they need to support multiple languages, they need to introduce a new complex layer (like Protobuf), So this just won't be used outside of Node servers then?
- gethly 1y agoThis looks like something you tinker with during a weekend when you are bored out of your mind.
- truth_seeker 1y agoJSON Serialization, seriously ??? Why not "application/octet-stream" header and sending ArrayBuffer over the network ?
- kentonv 1y agoBecause JSON serialization is built into the browser. I'm obviously a huge fan of binary serialization; I wrote Cap'n Proto and Protobuf v2 after all. But when you're working with pure JS, it's hard to be much faster than the built-in JSON implementation, and even if you can beat it, you're only going to get there with a lot of code, and in a browser code footprint often matters more than runtime speed.
- prngl 1y agoThis is cool. There's an interesting parallel with ML compilation libraries (TensorFlow 1, JAX jit, PyTorch compile) where a tracing approach is taken to build up a graph of operations that are then essentially compiled (or otherwise lowered and executed by a specialized VM). We're often nowadays working in dynamic languages, so they become essentially the frontend to new DSLs, and instead of defining new syntax, we embed the AST construction into the scripting language. For ML, we're delaying the execution of GPU/linalg kernels so that we can fuse them. For RPC, we're delaying the execution of network requests so that we can fuse them. Of course, compiled languages themselves delay the execution of ops (add/mul/load/store/etc) so that we can fuse them, i.e. skip over the round-trip of the interpreter/VM loop. The power of code as data in various guises. Another angle on this is the importance of separating control plane (i.e. instructions) from data plane in distributed systems, which is any system where you can observe a "delay". When you zoom into a single CPU, it acknowledges its nature as a distributed system with memory far away by separating out the instruction pipeline and instruction cache from the data. In Cap'n Web, we've got the instructions as the RPC graph being built up. I just thought these were some interesting patterns. I'm not sure I yet see all the way down to the bottom though. Feels like we go in circles, or rather, the stack is replicated (compiler built on interpreter built on compiler built on interpreter ...). In some respect this is the typical Lispy code is data, data is code, but I dunno, feels like there's something here to cut through...
- ryanrasti 1y agoAgree -- I think that's a powerful generalization you're making. > We're often nowadays working in dynamic languages, so they become essentially the frontend to new DSLs, and instead of defining new syntax, we embed the AST construction into the scripting language. And I'd say that TypeScript is the real game-changer here. You get the flexibility of the JavaScript runtime (e.g., how Cap'n Web cleverly uses `Proxy`s) while still being able to provide static types for the embedded DSL you're creating. It’s the best of both worlds. I've been spending all of my time in the ORM-analog here. Most ORMs are severely lacking on composability because they're fundamentally imperative and eager. A call like `db.orders.findAll()` executes immediately and you're stuck without a way to add operations before it hits the database. A truly composable ORM should act like the compilers you mentioned: use TypeScript to define a fully typed DSL over the entirety of SQL, build an AST from the query, and then only at the end compile the graph into the final SQL query. That's the core idea I'm working on with my project, Typegres. If you find the pattern interesting: https://typegres.com/play/ https://typegres.com/play/
- deepsun 1y agoThe main problem was always the same -- all the RPC libraries are designed to hide where the round-trip happens, but in real world you always want to know where and how the round-trip happens. Just read about Cap'n Web array .map() [1] -- it's hard to understand where the round-trip is. And that is not a feature, that's a bug -- in reality you want to easily tell what the code does, not hide it. [1] https://blog.cloudflare.com/capnweb-javascript-rpc-library/#but-how-do-we-solve-arrays https://blog.cloudflare.com/capnweb-javascript-rpc-library/#...
- kentonv 1y agoThe round trip happens when you `await` the result. You can tell that promise pipelining isn't adding any round trips because you set it all up in a series of statements without any `await`s. At the end you do one `await`. That's your round trip.
- degamad 1y agoYou say "round trip", but you mean "return trip", right? Because if I understand correctly, you don't queue the requests and then perform a single request/response cycle (a "round trip"), you send a bunch of requests as they happen with no response expected, then when an await happens, you send a message saying "okay, that's all, please send me the result" and get a response.
- crabmusket 1y agoNo, the requests are queued and sent as a batch.
- degamad 1y agoAh, okay, that's much better then... In principle, that then allows the server to aggregate or optimise the operations rather than performing them as written. While that might not be relevant for version 1, it's useful to have for later.
- isaiahballah 1y agoThis looks awesome! Does anyone know if this also works in React Native?
- thethimble 1y agoShould work anywhere that supports WS/HTTP + JS so yes
- kentonv 1y agoI haven't tested it, but I'd be surprised if it doesn't work. I'd happily accept a PR adding a CI job to make sure it works and we don't break it.
- paradox460 1y agoFeels a little like how erlang calls things across different nodes
- srameshc 1y agoIf you have ever had to use gRPC & web, then you will know how painful Protobuf is to make it work on the web. I love the simplicity of Cap'n Web https://capnproto.org/language.html https://capnproto.org/language.html , hopefully this will lead us to better and easier RPC. Update: Unlike Cap'n Proto, Cap'n Web has no schemas. In fact, it has almost no boilerplate whatsoever. This means it works more like the JavaScript-native RPC system in Cloudflare Workers. https://github.com/cloudflare/capnweb https://github.com/cloudflare/capnweb
- thethimble 1y agoI'm curious about two things: 1. What's the best way to do app deploys that update the RPC semantics? In other words how do you ensure that the client and server are speaking the same version of the RPC? This is a challenge that protos/grpc/avro explicitly sought to solve. 2. Relatedly, what's the best way to handle flaky connections? It seems that the export/import table is attached directly to a stateful WS connection such that if the connection breaks you'd lose the state. In principle there should be nothing preventing a client/server caching this state and reinstantiating it on reconnect. That said, given these tables can contain closures, they're not exactly serializable so you could run into memory issues. Curious if the team has thought about this. Absolutely mind blowing work!
- kentonv 1y ago1. Think of it like updating a JavaScript API without breaking existing callers. The rules are essentially the over RPC as they would be for local calls. So, you can add new methods and new optional arguments, etc. 2. After losing the connection, you'll have to reconnect and reconstruct the objects from scratch. The way I've structured this in an actual React app is, I pass the main RPC stub as an argument to the top-level component. It calls methods to get sub-objects and passes them down to various child components. When the connection is lost, I recreate it, and then pass the new stub into the top-level component, causing it to "rerender" just like any other state change. All the children will fetch the sub-objects they need again. If you have an object that represents some sort of subscription with a callback, you'll need to design the API so that when initiating the subscription, the caller can specify the last message they saw on the subscription, so that it can pick up where they left off without missing anything. Hmm, I suppose we'll need to do a blog post of design patterns at some point...
- thethimble 1y agoA blog post of design patterns would be really great. Again - amazing work!
- sgarland 1y agoTangential from a discussion in TFA about GraphQL: > One benefit of GraphQL was to solve the “waterfall” problem of traditional REST APIs by allowing clients to ask for multiple pieces of data in one query. For example, instead of making three sequential HTTP calls: GET /user GET /user/friends GET /user/friends/photos …you can write one GraphQL query to fetch it all at once. Or you could have designed a schema to allow easy tree traversal. Or you could use a recursive CTE.
- bern4444 1y agoThis looks awesome, I had two questions: Is there a structured concurrency library being used to manage the chained promise calls and lazy evaluation (IE when the final promise result is actually awaited) of the chained functions? If an await call is never added, would function calls continue to build up taking up more and more memory - I imagine the system would return an error and clear out the stack of calls before it became overwhelmed, what would these errors look like if they do indeed exist?
- kentonv 1y ago> Is there a structured concurrency library being used to manage the chained promise calls Cap'n Web has no dependencies at all. All the chaining is implemented internally. Arguably, this is the main thing the library does; without promise chaining you could cut out more than half the code. > If an await call is never added, would function calls continue to build up taking up more and more memory Yes. I recommend implementing rate limits and/or per-session limits on expensive operations. This isn't something the library can do automatically since it has no real idea how expensive each thing is. Note you can detect when the client has released things by putting disposers on your return values, so you can keep count of the resources the client is holding.
- porridgeraisin 1y agoI really like the idea. Especially the idea where you return different RpcTargets based on the "context", that's really quite nice. Not just for authentication and such, but for representing various things like "completely structurally different output for GET /thingies for admin and users". The promise-passing lazily evaluated approach is also nice -- any debugging woes are solved by just awaiting and logging before the await -- and it solves composability at the server - client layer. The hackiness of `map()` is unfortunate, but that's just how JS is. However, I don't see this being too useful without there also being composability at the server - database layer. This is notoriously difficult in most databases. I wonder what the authors / others here think about this. For an example of what I mean const user = rpc.getUser(id) const friends = await rpc.getFriends(user) Sure beats GET /user/id GET /graph?outbound=id But, at the end, both cases are running two different SQL queries. Most of the time when we fuse operations in APIs we do it all the way down to the SQL layer (with a join). GET /user/id?include=friends Which does a join and gets the data in a single query. So while its a nice programming model for sure, I think in practice we'll end up having a `rpc.getUserAndFriends()` anyways. I'm not that experienced, so I don't know in how many projects composability at just one layer would actually be enough to solve most composability issues. If it's a majority, then great, but if not, then I don't think this is doing much. One situation where this actually works that comes to mind is SQLite apps, where multiple queries are more or less OK due to lack of network round trip. Or if your DB is colocated with your app in one of the new fancy datacenters where you get DB RAM to app RAM transfer through some crazy fast network interconnect fabric (RDMA) that's quicker than even local disk sometimes.
- osigurdson 1y ago>> let namePromise = batch.getMyName(); let result = await batch.hello(namePromise); This is quite interesting. However the abysmal pattern I have seen a number of times is: list = getList(...) for item in list getItemDetails(item) Sometimes this is quite hard to undo.
- kentonv 1y agoKeep reading, the blog post addresses this.
- osigurdson 1y ago>> If a class extends the special marker type RpcTarget, then instances of that class are passed by reference, with method calls calling back to the location where the object was created. This is like .NET Remoting. Suggest resisting the temptation to use this kind of stuff. It gets very hard to reason about what is going on.
- spullara 1y agoWhich was directly based on Java RMI. Generally, not thought of as great ideas.
- mpweiher 1y agoBig fan of Cap'n'Proto and this looks really interesting, if RPC is the thing that works for your use-case. However, stumbled over this: The fact is, RPC fits the programming model we're used to. Every programmer is trained to think in terms of APIs composed of function calls, not in terms of byte stream protocols nor even REST. Using RPC frees you from the need to constantly translate between mental models, allowing you to move faster. The fact that this is, in fact, true is what I refer to as "The gentle tyranny of Call/Return" We're used to it, doing something more appropriate to the problem space is too unfamiliar and so more or less arbitrary additional complexity is...Just Fine™. https://www.hpi.uni-potsdam.de/hirschfeld/publications/media/Weiher_2020_CanProgrammersEscapeTheGentleTyrannyOfCallReturn_AcmDL.pdf https://www.hpi.uni-potsdam.de/hirschfeld/publications/media... Maybe it shouldn't actually be true. Maybe we should start to expand our vocabulary and toolchest beyond just "composed function calls"? So composed function calls are one tool in our toolchest, to be used when they are the best tool, not used because we have no reasonable alternative. https://blog.metaobject.com/2019/02/why-architecture-oriented-programming.html https://blog.metaobject.com/2019/02/why-architecture-oriente...
- kentonv 1y agoEh, I think composed function calls have legitimately won _because_ they compose well and are easy to understand, not just because we haven't tried other things.
- mpweiher 1y ago1. The WWW would like a quick word with you regarding function calls having won. 2. You are making an invalid assumption, which is that we only get to have one tool in our toolbox, and therefore one tool has to "win". Even if function calls were the best tool, they would still not always be the right one. With the benefit of hindsight, it’s clear that these properties of structured programs, although helpful, do not go to the heart of the matter. The most important difference between structured and unstructured programs is that structured programs are designed in a modular way. Modular design brings with it great productivity improvements. First of all, small modules can be coded quickly and easily. Second, general-purpose modules can be reused, leading to faster development of subsequent programs. Third, the modules of a program can be tested independently, helping to reduce the time spent debugging. However, there is a very important point that is often missed. When writing a modular program to solve a problem, one first divides the problem into subproblems, then solves the subproblems, and finally combines the solutions. The ways in which one can divide up the original problem depend directly on the ways in which one can glue solutions together. Therefore, to increase one’s ability to modularize a problem conceptually, one must provide new kinds of glue in the programming language. -- John Hughes, Why Functional Programming Matters https://www.cse.chalmers.se/~rjmh/Papers/whyfp.pdf https://www.cse.chalmers.se/~rjmh/Papers/whyfp.pdf via https://blog.metaobject.com/2019/02/why-architecture-oriented-programming.html https://blog.metaobject.com/2019/02/why-architecture-oriente... 3. Procedure calls are not particularly composable See CORBA vs. REST.
- jensneuse 1y agoI'm with WunderGraph, a vendor providing enterprise tooling for GraphQL. First, I absolutely love Capn Proto and the ideas of chaining calls on objects. It's amazing to see what's possible with CapNweb. However, one of the examples compares it to GraphQL, which I think falls a bit short of how enterprises use the Query language in real life. First, like others mentioned, you'll have N+1 problems for nested lists. That is, if we call comments() on each post and author() on each comment, we absolutely don't want to have one individual call per nested object. In GraphQL, with the data loader pattern, this is just 3 calls. Second, there's also an element of security. Advanced GraphQL gateways like WunderGraph's are capable of implementing fine grained rate limiting that prevent a client to ask for too much data. With this RPC object calling style, we don't have a notion of "Query Plans", so we cannot statically analyze a combination of API calls and estimate the cost before executing them. Lastly, GraphQL these days is mostly used with Federation. That means a single client talks to a Gateway (e.g. WunderGraph's Cosmo Router) and the Router distributed the calls efficiently across many sub services (Subgraphs) with a query planner that finds the optimal way to load information from multiple services. While capNweb looks amazing, the reality is that a client would have to talk to many services. Which brings me to my last point. Instead of Going the capNweb vs GraphQL route, I'd think more about how the two can work together. What if a client could use CapNweb to talk to a Federation Router that allows it to interact with entities, the object definitions in a GraphQL Federation system. I think this is really worth exploring. Not going against other API styles but trying to combine the strengths. - https://wundergraph.com/ https://wundergraph.com/
- jensneuse 1y agoSide note, a lot of people these days build agents on top of APIs. GraphQL has selection sets, which allows you to select subsets of objects. This is quite handy when it comes to gents because of context window limitations.
- rhaps0dy 1y ago> First, like others mentioned, you'll have N+1 problems for nested lists. That is, if we call comments() on each post and author() on each comment, we absolutely don't want to have one individual call per nested object. In GraphQL, with the data loader pattern, this is just 3 calls. Why is that a problem? As far as I can tell, those calls are all done on the server, where they're cheap normal function calls, and the results are all sent back with 1 roundtrip; because of the pipelining.
- pjmlp 1y agoI am going to be that guy, the way REST is used in 99% of project deployments, it is yet another form of RPC, mostly JSON-RPC with a little help of HTTP verbs to save having yet another field for the actuall message purpose, all nicely wrapped in language specific SDKs, looking like method/function calls.
- 3cats-in-a-coat 1y agoOn the path they've started with pipelining calls parametric on previous calls, they'll quickly realize that eventually they'll need basic transforms on the output before they pass it as input i.e. necessitating support for a standard runtime with a standard library of features. I.e. imagine you need this: promise1 = foo.call1(); promise2 = foo.call2(); promise3 = foo.call2(promise1 + promise2); Can't implement that "+" there unless... promise1 = foo.call1(); promise2 = foo.call2(); promise3 = foo.add(promise1, promise2) promise4 = foo.call2(promise3); You can also make some kind of a RpcNumber object so you can use their Proxy function to do promise1.add(promise2) but ultimately you don't want to write such classes on the spot every time. Or functions on the server for it. The problem is even that won't give you conditions (loops, branches) that run on the server, the server execution is blocked by the client. Once you realize THAT, you realize it's most optimal if both sides exchange command buffers in general, including batch instructions to remote and local calls and standardized expression syntax and library. What they did with array.map() is cute but it's not obvious what you can and what you can't do with this, and most developers will end up tripping up every time they use it, both trying to overuse this feature and underusing it, unaware of what it maps, how, when and where. For example this record replay can't do any (again...) arithmetic, logic, branching and so on. It can record calling method on the Proxy and replaying this on the other side, in simple containers, like an object literal. This is where GraphQL is better because it's an explicit buffer send and an explicit buffer return. The number of roundtrips and what maps how is not hidden. GraphQL has its own mess of poorly considered features, but I don't think Cap'n Web survives prolonged contact with reality because of how implicit and magical everything is. When you make an abstraction like this, it needs to work ALL THE TIME, so you don't have to think about it. If it only works in demo examples written by developers who know exactly when the abstraction breaks, real devs won't touch it.
- _1tem 1y agoBrilliantly engineered but this is solving all the wrong problems. The author implicates that this is supposed to be a better GraphQL/REST, but the industry is already moving towards a better solution for that[1]: data sync like ElectricSQL/Turso/litefs/RxDb. If you want to collapse the API boundary between server and client so that it "feels like" the server and client are the same, then sync the relevant data so it actually IS the same. Otherwise DON'T pretend it is the same because you will have badly leaking abstractions. This looks like it breaks all of the assumptions that programmers have about locally running code. Now every time I do a function call() I have to think about how to handle network failures and latency? What this could've been is a better way to consume external APIs to avoid the SDK boilerplate generation dance. But the primary problems here are access control, potentially malicious clients, and multi-language support, none of which are solved by this system. In short, if you're working over a network boundary, better keep that explicit. If you want to pretend the network boundary doesn't exist, then let a data sync engine handle the network parts and only write local code. But why would you write code that pretends to be local but is actually over a network boundary? I can't think of a single case where I would want to do that, I'd rather explicitly deal with the network issues so I can clearly see where the boundary is. [1] https://bytemash.net/posts/i-went-down-the-linear-rabbit-hole/ https://bytemash.net/posts/i-went-down-the-linear-rabbit-hol...
- gwbas1c 1y agoI've built similar things in the past. (And apologies for merely skimming the article.) In general, I worry that framework frameworks like this could be horribly complex; breaking bugs (in the framework) might not show up until late in your development cycle. This could mean that you end up having to rewrite your product sooner than you would like, or otherwise "the framework gets in the way" and cripples product development. Some things that worry me: 1: The way that callbacks are passed through RPC. This requires a lot of complexity in the framework to implement. 2: Callbacks passed through RPC implies server-side state. I didn't read in detail how this is implemented; but server-side state always introduces a lot of complexity in code and hosting. --- Personally, if that much server-side state is involved, I think it makes more sense to operate more like a dumb terminal and do more HTML rendering on the server. I'm a big fan of how server-side Blazor does this, but that does require drinking C# kool-aide. On the other hand, server-side Blazor is very mature, has major backing, and is built into two IDEs.
- gwbas1c 1y agoI should have added: One of the advantages of server-side Blazor (in C#) is that the server essentially returns partial bits of HTML for incremental page updates. This allows you to write UI code without needing to jump through all the hoops of creating a full API between your UI and server. (IE, you can write quick-and-dirty pages where the UI directly queries the database. Useful for one-offs, prototypes, internal admin pages, "KISS" applications, ect, ect. IE, any situation where it's okay for the browser UI to be tightly coupled to your data model.)
- jzig 1y agoHey kentonv, this looks really neat. How would you go about writing API documentation for something like this? I really like writing up OpenAPI YAML documents for consumers of APIs I write so that any time someone asks a question, "How do I get XYZ?" I can just point them to e.g. the SwaggerUI. But I'm struggling to understand how that would work here.
- kentonv 1y agoFor public-facing APIs, I would strongly recommend writing the TypeScript interfaces in a separate file from any implementation, and using JSDoc comments. There are various documentation generators that can turn that into a web page, though personally as a user I tend to prefer to just look at the actual TS file.
- coreload 1y agoLanguage specific RPC. At least Cap'n Proto is language agnostic. ConnectRPC is language agnostic and web compatible and a gRPC extended subset. I would have difficulty adopting a language specific RPC implementation.
- kentonv 1y agoThe protocol itself is really only language-specific to a similar extent that JSON is language-specific. Which you can totally argue it is, but also people have figured out how to use it in lots of languages other than JavaScript. I think Cap'n Web could work pretty well in Python and other dynamically-typed languages. Statically-typed would be a bit trickier (again, in the same sense that they are harder to use JSON in), but the answer there might just be to bridge to Cap'n Proto...
- JensRantil 1y agoI struggle to find why it would be appealing to use an RPC framework which is only targetted to TypeScript (and I guess, JavaScript). The point, for me, of an RPC framework is that it should be platform agnostic to allow for reuse and modularity.
- youngbum 1y agoYesterday, I migrated my web worker codes from Comlink to CapnWeb. I had extensive experience with Cloudflare Worker bindings, and as mentioned in the original post, they were quite similar. Everything appears to be functioning smoothly, but I do miss the ‘transfer’ feature in Comlink. Although it wasn’t a critical feature, it was a nice one. The best aspect of CapnWeb is that we can reuse most of the code related to clients, servers, and web workers (including Cloudflare Workers).
- kiitos 1y ago> Why RPC? (And what is RPC anyway?) RPC is an ambiguous and abstract umbrella-term for any request-response communication between two nodes that don't share the same memory space, usually over a network connection, and always via serialized bytes. > Without RPC, you might communicate using a protocol like HTTP. HTTP is probably the most common protocol for implementing RPC, indeed > With HTTP, though, you must format and parse your communications as an HTTP request and response, perhaps designed in REST style. yep! HTTP/REST is one good option, among many, for doing RPC communications > RPC systems try to make communications look like a regular function call instead, as if you were calling a library rather than a remote service. The RPC system provides a "stub" object on the client side which stands in for the real server-side object. Er, no, if you introduce the concept of "objects" you're now talking about something quite different than RPC, more akin to CORBA, which we well know now is not sound... > When a method is called on the stub, the RPC system figures out how to serialize and transmit the parameters to the server, invoke the method on the server, and then transmit the return value back. I mean, sure, but this is just normal serialization protocol stuff, right? > The merits of RPC have been subject to a great deal of debate. RPC is often accused of committing many of the fallacies of distributed computing. But this reputation is outdated. When RPC was first invented some 40 years ago, async programming barely existed. We did not have Promises, much less async and await. Early RPC was synchronous: calls would block the calling thread waiting for a reply. At best, latency made the program slow. At worst, network failures would hang or crash the program. No wonder it was deemed "broken". Things are different today. We have Promise and async and await, and we can throw exceptions on network failures. We even understand how RPCs can be pipelined so that a chain of calls takes only one network round trip. Many large distributed systems you likely use every day are built on RPC. It works. What a confusion of terms and concepts! Promises and async/await are language-level concepts, completely orthogonal to "RPC" which is a transport/wire-level concept -- what is happening here!! :o > The fact is, RPC fits the programming model we're used to. Every programmer is trained to think in terms of APIs composed of function calls, not in terms of byte stream protocols nor even REST. Using RPC frees you from the need to constantly translate between mental models, allowing you to move faster. i do not think RPC means what this author thinks that it means
- kentonv 1y ago
- benpacker 1y agoDoes this have support for generators / streams, or any ideas how you'd do it? With LLMs that an increasingly large part of client/server communication.
- kentonv 1y agoIt does better. It supports callbacks, including callback objects with multiple methods. So to define a stream, you just send the other side an object with a `write()` and an `end()` method. The other side calls `write()` for each chunk of the stream, then eventually calls `end()`. You can also give the stream a `[Symbol.dispose]()` method which will be called when the other side disconnects, so you can detect premature disconnect.